# VENDORED COPY for contract tests — canonical source:
# robinhood-launcher docs/slop-studio/openapi.yaml (the cross-lane seam).
# Do not edit here; spec changes go through the founder/overseer.
# Slop Studio render API — THE contract for P1.
# One render service, three front doors: the tokenslop "Promote" button, the
# standalone /studio page, and the public API / MCP tool. All of them call
# EXACTLY these endpoints — there are no private variants.
#
# Service lives in tokenslop-content (Lane A), runs on the content box next to
# the ticker (which stays untouched). See ARCHITECTURE.md for internals,
# TEMPLATE-CATALOG.md for template semantics, LANES.md for build lanes.
#
# Pricing is LOCKED (founder, 2026-07): text-pack $0.50 · cards $1 ·
# spotlight-clip $3 · board-clip $5 · packs $15/1,650cr $40/4,600cr
# $100/12,500cr · 1 credit = $0.01.
openapi: 3.0.3
info:
  title: Slop Studio API
  version: 2.2.0
  description: |
    Paid content-as-a-service on real chain data. There is NO free tier —
    every render is paid. Flow:

    **Card checkout (Stripe) — the retail path:**
    1. `POST /v1/checkout` `{kind: render, …intent, quantity?}` → `{orderId,
       checkoutUrl, expiresAt}`. `quantity` (1–10, default 1) buys N copies
       of the SAME render in one checkout — the price is unit × quantity and
       the Stripe receipt reads "Product × N". Every copy renders with a
       DIFFERENT deterministic variety-matrix seed (theme/background/voice/
       music rotate per copy — N distinct looks, "more shots at viral").
    2. Buyer pays on Stripe Checkout (statement descriptor suffix
       SLOPSTUDIO); success redirects to
       `${STUDIO_WEB_ORIGIN}/studio?order={orderId}`.
    3. The signature-verified Stripe webhook marks the order `paid` and
       atomically enqueues ALL its renders — buyers who close the tab still
       get their media.
    4. Client polls `GET /v1/checkout/{orderId}` until `jobIds` appears
       (`jobId` = the first copy, kept for older clients), then
       `GET /v1/jobs/{jobId}` per copy until `done` → `result.mediaUrl`.

    **API key (agents / B2B, prepaid credits):** `Authorization: Bearer sk_…`
    on `POST /v1/render`; credits are debited per render (1 credit = $0.01
    USD). Optional `quantity` (1–10) debits `priceCredits × quantity` and
    returns `jobIds` — same per-copy variety seeding as card orders. Top up
    with `POST /v1/checkout` `{kind: credits, packId}` (Bearer required).

    **Chain pay (PARKED rail):** the `/quotes` flow ships dark. `GET
    /v1/templates` advertises `payments: {card, chainPay}`; when `chainPay`
    is false (the production default) the `/quotes` routes are NOT
    registered (404) and clients must not offer on-chain payment.
    Feature-detect from the catalog — never assume.

    **Eligibility screening:** only chain-native meme/community tokens
    render. Equity-category (tokenized stocks), security-like, risk-flagged,
    and unbucketable tokens are refused with `403 token_not_eligible` — at
    checkout AND at render intake (deny-by-default).

    **Branding & disclosure:** every retail render carries a non-removable
    disclosure line — "paid promotional content · made with Slop Studio", or
    "sponsored by the $SYM team" when the buyer self-identifies via
    `sponsored: true` — plus the outro CTA "Live $SYM stats on
    tokenslop.fun" (linking tokenslop.fun/t/<address>). Enterprise-tier keys
    drop Slop brand marks; the disclosure line stays.

    Media URLs are presigned and expire (`result.expiresAt`) — download, don't
    hotlink.
servers:
  - url: https://studio-api.tokenslop.fun/v1
    description: production (CNAME to the content box; service binds STUDIO_PORT, default 4020)
  - url: http://localhost:4020/v1
    description: local dev

tags:
  - name: templates
  - name: render
  - name: jobs
  - name: checkout
  - name: kits
  - name: payments
  - name: keys
  - name: auth
  - name: clips
  - name: images
  - name: meta

security: []   # default public; per-operation bearerAuth where noted

paths:
  /templates:
    get:
      tags: [templates]
      operationId: listTemplates
      summary: Template catalog — ids, params, prices, preview thumbs
      description: |
        Public, cacheable (Cache-Control max-age=300). The /studio page and the
        MCP tool render their pickers from this — templates are added by
        shipping them in the service, never hardcoded in clients. Payment
        rails come from `payments` (capability object) — clients MUST
        feature-detect card/chain-pay from it.
      responses:
        "200":
          description: catalog
          content:
            application/json:
              schema:
                type: object
                required: [templates, credits, payments]
                properties:
                  templates:
                    type: array
                    items: { $ref: "#/components/schemas/Template" }
                  credits:
                    $ref: "#/components/schemas/CreditPacksInfo"
                  payments:
                    $ref: "#/components/schemas/PaymentsCapability"
                  vibes:
                    $ref: "#/components/schemas/VibesCapability"

  /kits/generate:
    post:
      tags: [kits]
      operationId: generateKit
      summary: Generate a Brand Kit (reference stills + music bed) from a vibe prompt
      description: |
        Creates a kit keyed by chain+token. Requires `attested: true` (legal
        confirmation). Payment: Bearer API key debits `vibes.priceCredits` and
        generates synchronously; without a key returns a Stripe checkout order
        (`kind: vibes`) — poll `GET /v1/checkout/{orderId}` for `kitId` after pay.
        Catalog-gated via `vibes.enabled` on GET /v1/templates.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/KitGenerateRequest" }
      responses:
        "201":
          description: ready kit (credits path) or checkout order (card path)
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/Kit"
                  - $ref: "#/components/schemas/CheckoutOrder"
        "400":
          description: invalid request (e.g. attested not true)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
        "402":
          description: insufficient credits (Bearer path)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }

  /kits/{kitId}:
    get:
      tags: [kits]
      operationId: getKit
      summary: Poll kit status and asset URLs
      parameters:
        - name: kitId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: kit status
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Kit" }
        "404":
          description: kit not found
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }

  /kits/{kitId}/assets/{filename}:
    get:
      tags: [kits]
      operationId: getKitAsset
      summary: Download a generated kit asset file
      parameters:
        - name: kitId
          in: path
          required: true
          schema: { type: string }
        - name: filename
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: asset bytes (image/png, audio/mpeg, …)
          content:
            application/octet-stream:
              schema: { type: string, format: binary }
        "404":
          description: not found
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }

  /render:
    post:
      tags: [render]
      operationId: createRender
      summary: Create a render job (PAID ONLY — credits, order retry, or quote)
      description: |
        There is no free tier. Exactly one payment mode:

        1. **API key** (`Authorization: Bearer sk_…`): debits
           `template.priceCredits` atomically at job creation; refunded
           automatically if the job ends `failed`. Optional `quantity`
           (1–10, default 1) creates N copies of the render in ONE atomic
           debit of `priceCredits × quantity`: the `201` is the first copy's
           Job plus `jobIds` (all copies, index = copy). Each copy renders
           with a DIFFERENT deterministic variety-matrix seed (distinct
           theme/background pair per copy; voice/music rotate too) —
           buyer-set style options stay fixed across copies. A failed copy
           auto-refunds exactly its own share.
        2. **Paid order retry** (`payment.orderId`): card buyers normally
           never call this — the Stripe webhook enqueues their job(s). If a
           SINGLE-copy (`quantity: 1`) paid job ends `failed`, the order
           resets to `paid` and one re-render is allowed here. On a
           MULTI-copy order a failed copy does NOT reset the order — retry
           exactly that copy with `payment: {orderId, copyIndex}`; the slot
           accepts a retry only while its current job is `failed`
           (`order_consumed` otherwise), and the new job reuses the copy's
           original variety seed. Either way the order's bound
           `template`+`chain`+`tokenAddress(es)` must equal this request
           (`order_mismatch` otherwise) and `quantity` must be omitted
           (it is fixed at checkout).
        3. **Paid quote** (`payment.quoteId`, PARKED chain rail): quote must
           be `paid`, unconsumed, and its bound intent must equal this
           request (style options are free to differ). Consumes the quote.
           Only meaningful when `payments.chainPay` is true. `quantity` is
           not supported on this rail (one quote = one render).

        Bare requests (no key, no payment) → `402 payment_required` pointing
        at `POST /v1/checkout`. Token screening applies here too
        (`403 token_not_eligible`).

        `Idempotency-Key` header (≤64 chars): retries with the same key return
        the original job(s) instead of double-charging. A given paid txHash
        can only ever fund one job (replay protection).
      parameters:
        - name: Idempotency-Key
          in: header
          required: false
          schema: { type: string, maxLength: 64 }
      security:
        - {}
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RenderRequest" }
            examples:
              keyTextPack:
                summary: credits-paid text pack with links (Bearer header)
                value:
                  template: text-pack
                  chain: rhc
                  tokenAddress: "0x1234abcd1234abcd1234abcd1234abcd1234abcd"
                  links: { website: "https://woofcoin.example", x: "@woofcoin" }
              keyQuantity:
                summary: 3 copies via API key — one debit of priceCredits × 3, distinct look per copy
                value:
                  template: spotlight-clip
                  chain: rhc
                  tokenAddress: "0x1234abcd1234abcd1234abcd1234abcd1234abcd"
                  quantity: 3
              orderRetry:
                summary: re-render after a failed card-paid job (single-copy order reset to paid)
                value:
                  template: spotlight-clip
                  chain: rhc
                  tokenAddress: "0x1234abcd1234abcd1234abcd1234abcd1234abcd"
                  payment: { orderId: "ord_8a41f6d2c9e07b35" }
              copyRetry:
                summary: re-render ONE failed copy of a multi-copy card order (slot 2)
                value:
                  template: spotlight-clip
                  chain: rhc
                  tokenAddress: "0x1234abcd1234abcd1234abcd1234abcd1234abcd"
                  payment: { orderId: "ord_8a41f6d2c9e07b35", copyIndex: 2 }
              paidSpotlightClip:
                summary: paid clip via verified quote (PARKED chain rail)
                value:
                  template: spotlight-clip
                  chain: rhc
                  tokenAddress: "0x1234abcd1234abcd1234abcd1234abcd1234abcd"
                  options: { theme: miami, hookStyle: brand }
                  payment: { quoteId: "qt_9f2c81d0e4b74a51" }
              keyBoard:
                summary: B2B launcher board via API key (Bearer header)
                value:
                  template: board-clip
                  chain: rhc
                  tokenAddresses:
                    - "0x1111111111111111111111111111111111111111"
                    - "0x2222222222222222222222222222222222222222"
                    - "0x3333333333333333333333333333333333333333"
                  options: { aspect: "16:9", headline: "hottest on our pad" }
                  webhookUrl: "https://partner.example/hooks/slop"
      responses:
        "201":
          description: |
            job accepted. For a keyed `quantity > 1` render the body is the
            first copy's Job with an added `jobIds` array (one job id per copy,
            index = copy index); single renders omit it.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Job"
                  - type: object
                    properties:
                      jobIds:
                        type: array
                        items: { type: string }
                        description: "multi-copy (keyed quantity > 1) only: every copy's job id, ordered by copy index; `jobId` == `jobIds[0]`"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402":
          description: payment problem — `payment_required`, `insufficient_credits`, `order_unpaid`, `order_consumed`, `order_mismatch`, `quote_unpaid`, `quote_expired`, `quote_consumed`, `quote_mismatch`
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example:
                error:
                  code: payment_required
                  message: "Renders are paid — open a card checkout or present an API key."
                  details: { checkout: "POST /v1/checkout {kind: render, …}", packs: "POST /v1/checkout {kind: credits, packId}" }
        "403": { $ref: "#/components/responses/TokenNotEligible" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422":
          description: token unusable — `token_not_found`, `token_risk_blocked` (risk-flagged/scam-reputation tokens are refused per guardrails)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /jobs/{jobId}:
    get:
      tags: [jobs]
      operationId: getJob
      summary: Job status, progress, and media URL when done
      description: |
        Poll every 2–5s. Jobs are readable by anyone holding the (unguessable)
        jobId — media links are share-by-link by design. Terminal states:
        `done`, `failed`. Failed key-paid jobs auto-refund credits
        (`refunded: true`); failed card-paid jobs reset their order to `paid`
        for one free retry (`POST /v1/render` with `payment.orderId`).
      parameters:
        - name: jobId
          in: path
          required: true
          schema: { type: string, example: "job_7c1f0a92b3d84e6f" }
      responses:
        "200":
          description: job state
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Job" }
              example:
                jobId: job_7c1f0a92b3d84e6f
                status: done
                progress: 1
                template: spotlight-clip
                chain: rhc
                tokenAddress: "0x1234abcd1234abcd1234abcd1234abcd1234abcd"
                watermarked: false
                paidVia: stripe
                orderId: ord_8a41f6d2c9e07b35
                createdAt: "2026-07-19T18:21:04Z"
                startedAt: "2026-07-19T18:21:06Z"
                finishedAt: "2026-07-19T18:22:31Z"
                queuePosition: 0
                result:
                  mediaUrl: "https://media.tokenslop.fun/studio/job_7c1f0a92b3d84e6f/spotlight-clip.mp4"
                  thumbnailUrl: "https://media.tokenslop.fun/studio/job_7c1f0a92b3d84e6f/thumb.jpg"
                  contentType: video/mp4
                  width: 1080
                  height: 1920
                  durationSec: 11.7
                  sizeBytes: 8412345
                  expiresAt: "2026-07-26T18:22:31Z"
                  caption: "spotlight: $WOOF is ripping — +42.0% today.\n\nprice $0.0031 · +42.0% 24h · vol $1.2M · 5,120 holders\n\nthe receipts → tokenslop.fun"
                  snapshot:
                    symbol: WOOF
                    name: woofcoin
                    priceUsd: 0.0031
                    change24h: 42
                    volume24hUsd: 1200000
                    marketCapUsd: 3100000
                    holders: 5120
                    heat: 78
                    asOf: "2026-07-19T18:21:05Z"
        "404": { $ref: "#/components/responses/NotFound" }

  /checkout:
    post:
      tags: [checkout]
      operationId: createCheckout
      summary: Create a Stripe Checkout order (single render or credit pack)
      description: |
        The retail front door. `kind: render` binds the order to a full
        render intent (`template` + `chain` + `tokenAddress(es)` + options /
        links / sponsored) and screens the token FIRST — ineligible tokens
        are refused before any money moves. `kind: credits` (Bearer
        REQUIRED) tops up the presented key with `packId`'s credits on
        payment.

        Returns a Stripe Checkout Session URL (`checkoutUrl`, ~30 min
        expiry, statement descriptor suffix SLOPSTUDIO). On completion
        Stripe redirects to `${STUDIO_WEB_ORIGIN}/studio?order={orderId}`
        and the webhook settles the order server-side — paid render orders
        enqueue their job automatically (no client call needed).

        `Idempotency-Key` header (≤64 chars): retries with the same key
        return the ORIGINAL order + checkoutUrl instead of opening a second
        session.
      parameters:
        - name: Idempotency-Key
          in: header
          required: false
          schema: { type: string, maxLength: 64 }
      security:
        - {}
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CheckoutRequest" }
            examples:
              singleClip:
                summary: $3 spotlight clip with links, buyer is on the token team
                value:
                  kind: render
                  template: spotlight-clip
                  chain: rhc
                  tokenAddress: "0x1234abcd1234abcd1234abcd1234abcd1234abcd"
                  options: { theme: miami }
                  links: { website: "https://woofcoin.example", x: "@woofcoin" }
                  sponsored: true
              multiCopyClip:
                summary: 5 copies of a $3 clip in one checkout ($15 total, 5 distinct looks)
                value:
                  kind: render
                  template: spotlight-clip
                  chain: rhc
                  tokenAddress: "0x1234abcd1234abcd1234abcd1234abcd1234abcd"
                  quantity: 5
              creditPack:
                summary: $15 credit pack top-up (Bearer sk_… required)
                value:
                  kind: credits
                  packId: pack-15
      responses:
        "201":
          description: order created — send the buyer to `checkoutUrl`
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CheckoutOrder" }
              example:
                orderId: ord_8a41f6d2c9e07b35
                kind: render
                status: created
                checkoutUrl: "https://checkout.stripe.com/c/pay/cs_test_a1B2c3D4e5F6…"
                template: spotlight-clip
                chain: rhc
                tokenAddress: "0x1234abcd1234abcd1234abcd1234abcd1234abcd"
                priceUsd: 3.00
                quantity: 1
                createdAt: "2026-07-19T18:15:00Z"
                expiresAt: "2026-07-19T18:45:00Z"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/TokenNotEligible" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422":
          description: token unusable — `token_not_found`
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "451": { $ref: "#/components/responses/RegionBlocked" }

  /checkout/{orderId}:
    get:
      tags: [checkout]
      operationId: getCheckoutOrder
      summary: Order status (created | paid | consumed | expired | closed)
      description: |
        Poll after the success redirect (`/studio?order={orderId}`). `paid`
        flips within seconds of Stripe's webhook; render orders then expose
        `jobId` (webhook-enqueue, status `consumed`) — from there poll
        `GET /v1/jobs/{jobId}`. `closed` = closed/refunded via founder
        tooling.
      parameters:
        - name: orderId
          in: path
          required: true
          schema: { type: string, example: "ord_8a41f6d2c9e07b35" }
      responses:
        "200":
          description: order state
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CheckoutOrder" }
              examples:
                consumedSingle:
                  summary: single-copy render order, paid + enqueued
                  value:
                    orderId: ord_8a41f6d2c9e07b35
                    kind: render
                    status: consumed
                    checkoutUrl: null
                    template: spotlight-clip
                    chain: rhc
                    tokenAddress: "0x1234abcd1234abcd1234abcd1234abcd1234abcd"
                    priceUsd: 3.00
                    quantity: 1
                    jobId: job_7c1f0a92b3d84e6f
                    jobIds: ["job_7c1f0a92b3d84e6f"]
                    createdAt: "2026-07-19T18:15:00Z"
                    expiresAt: "2026-07-19T18:45:00Z"
                consumedMultiCopy:
                  summary: 3-copy render order — jobIds[] one per copy (jobId == jobIds[0])
                  value:
                    orderId: ord_8a41f6d2c9e07b35
                    kind: render
                    status: consumed
                    checkoutUrl: null
                    template: spotlight-clip
                    chain: rhc
                    tokenAddress: "0x1234abcd1234abcd1234abcd1234abcd1234abcd"
                    priceUsd: 9.00
                    quantity: 3
                    jobId: job_7c1f0a92b3d84e6f
                    jobIds:
                      - job_7c1f0a92b3d84e6f
                      - job_2b7d0c1e4a53f890
                      - job_3c8e1d2f5b64a901
                    createdAt: "2026-07-19T18:15:00Z"
                    expiresAt: "2026-07-19T18:45:00Z"
        "404": { $ref: "#/components/responses/NotFound" }

  /stripe/webhook:
    post:
      tags: [checkout]
      operationId: stripeWebhook
      summary: Stripe events (server-to-server — not for API clients)
      description: |
        Signature-verified on the RAW body (`Stripe-Signature` header +
        endpoint secret). Handles `checkout.session.completed`,
        `checkout.session.expired`, and async payment succeeded/failed
        events. Every event id is recorded before processing (replay guard);
        settlement asserts `amount_total` matches the order before
        atomically consuming it (enqueue render / grant credits).
        Documented here only so the deployed HTTP surface equals this
        contract.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: raw Stripe event envelope (verified against the signature header, not this schema)
              additionalProperties: true
      responses:
        "200":
          description: event received (processed or safely ignored)
          content:
            application/json:
              schema:
                type: object
                properties:
                  received: { type: boolean, example: true }
        "400":
          description: bad signature / unparseable payload — `invalid_request`
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /quotes:
    post:
      tags: [payments]
      operationId: createQuote
      summary: PARKED — price a render (or credit pack) → pay-to amount on RHC
      description: |
        **PARKED RAIL** — registered only when the deployment enables chain
        pay (`/templates` → `payments.chainPay: true`); otherwise these
        routes return 404. Clients MUST feature-detect and never offer
        on-chain payment when the flag is off.

        Returns a payment instruction on **Robinhood Chain mainnet (chainId
        4663), native ETH**. `amountWei` = USD price converted at quote time
        **plus a unique wei-level salt** (≤ ~1000 gwei, economically nil) so the
        exact value identifies the quote on-chain — RHC native transfers carry
        no memo. Send EXACTLY `amountWei` in ONE transaction to `payTo` before
        `expiresAt` (30 min). From-address does not matter (exchange
        withdrawals work).

        `purpose: render` binds the quote to `template` + `chain` +
        `tokenAddress(es)`; `POST /v1/render` must match them. `purpose:
        credits` (Bearer auth REQUIRED) adds `creditsOnSettle` to the key on
        verification. Underpays/overpays do NOT settle (`amount_mismatch`) —
        recovery is manual, so clients must surface "send the exact amount".
      security:
        - {}
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/QuoteRequest" }
            examples:
              renderQuote:
                value:
                  purpose: render
                  template: spotlight-clip
                  chain: rhc
                  tokenAddress: "0x1234abcd1234abcd1234abcd1234abcd1234abcd"
              creditsQuote:
                summary: credit pack top-up (Bearer sk_… required)
                value:
                  purpose: credits
                  packId: pack-50
      responses:
        "201":
          description: quote created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Quote" }
              example:
                quoteId: qt_9f2c81d0e4b74a51
                purpose: render
                status: pending
                priceUsd: 2.00
                priceCredits: 200
                template: spotlight-clip
                chain: rhc
                tokenAddress: "0x1234abcd1234abcd1234abcd1234abcd1234abcd"
                payment:
                  network: rhc
                  chainId: 4663
                  asset: ETH
                  payTo: "0xSTUDIO_PAYTO_ADDRESS_SET_AT_DEPLOY_TIME00"
                  amountWei: "533422000387219466"
                  amountEth: "0.000533422000387219"
                  ethUsdAtQuote: 3749.86
                  minConfirmations: 5
                createdAt: "2026-07-19T18:15:00Z"
                expiresAt: "2026-07-19T18:45:00Z"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /quotes/{quoteId}:
    get:
      tags: [payments]
      operationId: getQuote
      summary: Quote status (pending | paid | consumed | expired)
      parameters:
        - name: quoteId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: quote state
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Quote" }
        "404": { $ref: "#/components/responses/NotFound" }

  /quotes/{quoteId}/verify:
    post:
      tags: [payments]
      operationId: verifyQuote
      summary: Submit the payment txHash for on-chain verification
      description: |
        Server-side checks against RHC RPC (see ARCHITECTURE.md §payments):
        tx exists + receipt `status: success`; `to == payTo`; `value ==
        amountWei` EXACTLY; mined at/after quote creation; `confirmations >=
        minConfirmations`; txHash never used by any other quote (global
        replay protection). Idempotent — re-posting the same txHash on the
        same quote returns the current state. If confirmations are still
        accumulating, returns `202` with `status: pending` and
        `confirmations` so clients can poll (RHC blocks are sub-second; this
        resolves in seconds).
      parameters:
        - name: quoteId
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [txHash]
              properties:
                txHash:
                  type: string
                  pattern: "^0x[0-9a-fA-F]{64}$"
      responses:
        "200":
          description: verified — quote is `paid`
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Quote" }
        "202":
          description: seen but not enough confirmations yet — poll again
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Quote" }
        "402":
          description: verification failed — `tx_not_found`, `tx_reverted`, `wrong_recipient`, `amount_mismatch`, `tx_replayed`, `quote_expired`
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404": { $ref: "#/components/responses/NotFound" }

  /keys:
    post:
      tags: [keys]
      operationId: createKey
      summary: Self-serve API key (starts at 0 credits)
      description: |
        No KYC, no account — a key is just a funding bucket. The secret
        (`sk_live_…`, 32 bytes base58) is returned ONCE and stored hashed
        (SHA-256). Fund it via `POST /v1/checkout` `{kind: credits, packId}`
        (card; the parked chain rail also tops up when enabled). Abuse
        control: key creation is IP rate-limited; unfunded keys idle for 30
        days may be purged. B2B/partner grants: ops CLI on the box
        (`studio-admin grant`) credits any key manually.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                label: { type: string, maxLength: 64, example: "noxa-integration" }
                contact: { type: string, maxLength: 128, description: "optional email/telegram for outage notices — never required", example: "dev@partner.example" }
      responses:
        "201":
          description: key created — secret shown once
          content:
            application/json:
              schema:
                type: object
                required: [keyId, secret, credits]
                properties:
                  keyId: { type: string, example: "key_5a1e99c2" }
                  secret: { type: string, example: "sk_live_4uQ9…" }
                  label: { type: string, nullable: true }
                  credits: { type: integer, example: 0 }
                  createdAt: { type: string, format: date-time }
        "429": { $ref: "#/components/responses/RateLimited" }

  /keys/me:
    get:
      tags: [keys]
      operationId: getKeyInfo
      summary: Balance + usage for the presented key
      security:
        - bearerAuth: []
      responses:
        "200":
          description: key info
          content:
            application/json:
              schema:
                type: object
                required: [keyId, credits, totalSpentCredits, createdAt, rateLimit]
                properties:
                  keyId: { type: string }
                  label: { type: string, nullable: true }
                  tier:
                    type: string
                    enum: [standard, enterprise]
                    description: enterprise keys render without Slop brand marks (the disclosure line stays); granted via founder tooling
                  credits: { type: integer, description: "1 credit = $0.01 USD" }
                  totalSpentCredits: { type: integer }
                  jobCount: { type: integer }
                  createdAt: { type: string, format: date-time }
                  rateLimit:
                    type: object
                    properties:
                      requestsPerMinute: { type: integer, example: 60 }
                      concurrentJobs: { type: integer, example: 10 }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /keys/me/providers:
    get:
      tags: [keys]
      operationId: getKeyProviders
      summary: BYOA status — which provider credentials this key has stored
      description: |
        Bring-your-own-provider-API-keys. Never returns the plaintext
        secret — only whether a credential is stored, plus a 4-char
        `hint` (the secret's last 4 characters) so buyers can recognize
        which key they saved. Providers with no stored credential are
        `null` and fall back to this deployment's own env-configured key
        at generation time.
      security:
        - bearerAuth: []
      responses:
        "200":
          description: provider credential status
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ProviderCredentialStatus" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    put:
      tags: [keys]
      operationId: setKeyProviders
      summary: Store (upsert) one or more provider API keys for BYOA generation
      description: |
        Encrypts and stores any of `fal` / `elevenlabs` / `openrouter`
        present as a non-empty string; omitted or empty-string fields are
        left untouched (use `DELETE` to clear a credential). Once stored,
        `ai-meme-clip` renders and Brand Kit / Add Vibes generation for
        this key use the buyer's OWN provider credentials instead of this
        deployment's env-configured keys.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ProviderCredentialsUpdate" }
            examples:
              setFalAndEleven:
                value:
                  fal: "fal_sk_…"
                  elevenlabs: "sk_…"
      responses:
        "200":
          description: updated provider credential status
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ProviderCredentialStatus" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "503":
          description: this deployment has no PROVIDER_SECRETS_KEY configured — BYOA storage is unavailable (env-configured provider keys still work for every key)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
    delete:
      tags: [keys]
      operationId: deleteKeyProviders
      summary: Clear stored provider credentials (specific providers, or all)
      description: |
        `{providers: ["fal"]}` clears just that provider; an empty body
        (or omitting `providers`) clears every stored credential for this
        key, reverting all generation for it to this deployment's
        env-configured keys.
      security:
        - bearerAuth: []
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ProviderCredentialsDelete" }
      responses:
        "200":
          description: updated provider credential status
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ProviderCredentialStatus" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /auth/keys:
    get:
      tags: [auth]
      operationId: listAccountKeys
      summary: The profile page's key list — labels, timestamps and a MASK, never a secret
      description: |
        Account surface (Supabase in front, SQLite behind). A GET carries no
        body, so the Supabase ACCESS TOKEN travels as `Authorization: Bearer
        <token>` — NOT an `sk_live_…` API key. Verified against Supabase live
        on every call: the box holds no sessions, and a signed-out user is
        refused on the next request. `masked` is first 8 + `…` + last 4 of the
        vaulted secret; it is `null` for keys that predate the vault (roll the
        key to fix that). The plaintext secret is never in this response.
      security:
        - supabaseAuth: []
      responses:
        "200":
          description: the account's live keys, oldest first
          content:
            application/json:
              schema:
                type: object
                required: [keys]
                properties:
                  keys:
                    type: array
                    items: { $ref: "#/components/schemas/AccountKey" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "503":
          description: accounts not configured on this deployment — `accounts_unconfigured`
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
    post:
      tags: [auth]
      operationId: createAccountKey
      summary: Mint an additional API key on the account — plaintext crosses the wire exactly once
      description: |
        The Supabase access token travels in the BODY (never the query string,
        so it never lands in an access log). The minted secret comes back as
        `key`, exactly once — afterwards it is `POST
        /v1/auth/keys/{keyId}/reveal` (vault) or nothing. The webhook signing
        secret ships WITH the key, derived rather than stored. Key creation is
        IP rate-limited.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token, label]
              properties:
                token: { type: string, description: "Supabase access token" }
                label: { type: string, minLength: 1, maxLength: 40, description: "required, 1–40 chars (trimmed)", example: "Render farm" }
      responses:
        "201":
          description: key minted — the secret is shown here and never again
          content:
            application/json:
              schema:
                type: object
                required: [keyId, key, label, webhookSecret]
                properties:
                  keyId: { type: string, example: "key_5a1e99c2" }
                  key: { type: string, description: "the `sk_live_…` secret — shown once", example: "sk_live_4uQ9…" }
                  label: { type: string }
                  webhookSecret: { type: string, description: "HMAC key for `X-Slop-Signature` webhook verification — derived from the key, recoverable via GET /v1/keys/me" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503":
          description: accounts not configured on this deployment — `accounts_unconfigured`
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /auth/keys/{keyId}:
    delete:
      tags: [auth]
      operationId: deleteAccountKey
      summary: Revoke one of the account's keys — refused in plain words when it is the last one
      description: |
        Flips the same `revoked` switch every other revocation path uses; the
        key stops authenticating on its next request. The credit ledger is
        left alone — unspent credits stay on the dead row as history. Deleting
        the account's LAST key is a 400 (an account must keep at least one —
        roll it instead). Another account's key and a key that never existed
        are the same 404 (`key_not_found`).
      parameters:
        - name: keyId
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token]
              properties:
                token: { type: string, description: "Supabase access token" }
      responses:
        "200":
          description: revoked
          content:
            application/json:
              schema:
                type: object
                required: [ok]
                properties:
                  ok: { type: boolean, enum: [true] }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /auth/keys/{keyId}/reveal:
    post:
      tags: [auth]
      operationId: revealAccountKey
      summary: Reveal a vaulted key's plaintext secret — a deliberate, separate POST
      description: |
        Its own POST rather than a field on the list, so the secret crosses
        the wire only when the profile page's "show key" is actually clicked.
        Keys that predate the vault answer `409 not_revealable` — roll the key
        (new secret, same keyId, balances untouched) to make it revealable.
      parameters:
        - name: keyId
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token]
              properties:
                token: { type: string, description: "Supabase access token" }
      responses:
        "200":
          description: the plaintext secret
          content:
            application/json:
              schema:
                type: object
                required: [keyId, secret]
                properties:
                  keyId: { type: string }
                  secret: { type: string, example: "sk_live_4uQ9…" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: key predates the vault — `not_revealable` (roll it to make it revealable)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /clips:
    post:
      tags: [clips]
      operationId: ingestClip
      summary: Pull a span of the buyer's own footage into this key's clip library
      description: |
        Key-authed, rate-gated (60/min). Body: `{youtubeUrl, startSec?,
        endSec?, attest: true, label?}`. The attestation is a PRECONDITION,
        not a field — the literal `true`, checked before the URL is even
        parsed; `"true"`, `1` and truthy objects are refused
        (`rights_not_confirmed`), because we will not download footage without
        the buyer's confirmation that they own or are licensed to use it.

        The field is named `youtubeUrl` because that is what customers send;
        any allowed video host is accepted (youtube.com, youtu.be, vimeo.com,
        player.vimeo.com — exact video pages only, never a homepage or
        channel). A bare URL gets the first 15 seconds; explicit spans are
        capped at 120 seconds. `label` (optional, ≤200 chars, banned-claims
        linted) says what the footage shows — pre-labeled ingest is free.

        Refusals arrive in plain words: 400 `invalid_request` with a
        machine-readable `details.why` (`unsupported_url`, `span_invalid`, …),
        502 `render_failed` for upstream failures (`download_failed`,
        `transcode_failed`), and 409 `clip_quota_exceeded` when the library is
        full (retail 20 clips / 30 days retention; enterprise 200 / 90).
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ClipIngestItem" }
      responses:
        "201":
          description: clip pulled and filed
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ClipIngested" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "409":
          description: clip library full — `clip_quota_exceeded` (details carry tier, maxClips, stored; DELETE frees a slot)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "502":
          description: pull failed upstream — `render_failed` (details.why = download_failed | transcode_failed)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
    get:
      tags: [clips]
      operationId: listClips
      summary: The caller's clips — and only ever the caller's
      description: |
        The envelope carries the tier's limits beside the rows so a client can
        show "17 of 20, kept 30 days" without hardcoding numbers. `stored`
        restates `clips.length` — the quota counts exactly what this list
        shows.
      security:
        - bearerAuth: []
      responses:
        "200":
          description: stored clips + tier limits
          content:
            application/json:
              schema:
                type: object
                required: [clips, limits]
                properties:
                  clips:
                    type: array
                    items: { $ref: "#/components/schemas/Clip" }
                  limits:
                    type: object
                    required: [tier, maxClips, retentionDays, stored]
                    properties:
                      tier: { type: string, enum: [retail, enterprise] }
                      maxClips: { type: integer, example: 20 }
                      retentionDays: { type: integer, example: 30 }
                      stored: { type: integer }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /clips/batch:
    post:
      tags: [clips]
      operationId: ingestClipBatch
      summary: A library arrives as one call — up to 20 items, per-item consent, per-item results
      description: |
        Body: `{items: [{youtubeUrl, startSec?, endSec?, attest: true,
        label?}]}` (max 20). Results come back per item IN THE ORDER SENT: an
        item that fails does not take its neighbours down, and an item lacking
        its OWN `attest: true` is refused — a batch-level flag is never
        accepted in its place, because a batch is N consents, not one consent
        stretched over N downloads. Items run sequentially; the tier cap is
        enforced item by item.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [items]
              properties:
                items:
                  type: array
                  minItems: 1
                  maxItems: 20
                  items: { $ref: "#/components/schemas/ClipIngestItem" }
      responses:
        "200":
          description: per-item outcomes, order preserved
          content:
            application/json:
              schema:
                type: object
                required: [results, accepted, refused]
                properties:
                  results:
                    type: array
                    items:
                      type: object
                      required: [index, accepted]
                      properties:
                        index: { type: integer, description: "position in the request's items array" }
                        accepted: { type: boolean }
                        clipId: { type: string, description: "accepted items only" }
                        seconds: { type: number, description: "accepted items only" }
                        expiresAt: { type: string, format: date-time, description: "accepted items only" }
                        label: { type: string, nullable: true, description: "accepted items only" }
                        error:
                          type: object
                          description: refused items only — the same plain words the single route uses
                          required: [code, message]
                          properties:
                            code: { type: string }
                            message: { type: string }
                  accepted: { type: integer }
                  refused: { type: integer }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /clips/{clipId}:
    patch:
      tags: [clips]
      operationId: labelClip
      summary: The customer says what their clip shows — free
      description: |
        Body: `{label}` (required, ≤200 chars, banned-claims linted — "next
        100x" may not ride in on an asset annotation). Sets
        `labelSource: customer` — a customer's own words always overwrite a
        scan's, never the price of one. "Not yours" and "gone" are the same
        404.
      security:
        - bearerAuth: []
      parameters:
        - name: clipId
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [label]
              properties:
                label: { type: string, minLength: 1, maxLength: 200, example: "forklift moving pallets in a warehouse / logistics beats" }
      responses:
        "200":
          description: the updated clip
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Clip" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      tags: [clips]
      operationId: deleteClip
      summary: Delete a stored clip — row and bytes together, own only
      description: frees a quota slot immediately. Another key's clip and a clip that never existed are the same 404.
      security:
        - bearerAuth: []
      parameters:
        - name: clipId
          in: path
          required: true
          schema: { type: string }
      responses:
        "204":
          description: deleted — no body
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /clips/scan:
    post:
      tags: [clips]
      operationId: scanClips
      summary: The PAID scan-and-label pass — quote first, spend only on the literal confirm
      description: |
        Body: `{clipIds?, confirm?}`. Without `clipIds` the scan targets every
        UNLABELED live clip on the key; explicit ids may re-scan an
        already-labeled clip (the buyer is paying to re-look). The FIRST call
        answers with a quote and charges nothing; only a resend carrying the
        literal `confirm: true` spends — the same never-defaulted discipline
        as `attest`, because a charge that can be triggered by accident is not
        a quote. (The quote rides in this response's `quote` field — it is NOT
        a /v1/quotes chain-rail quote.)

        On confirm: the quoted credits are reserved as a CEILING, a model
        looks at sampled frames and writes one label per clip
        (`labelSource: scan`), and whatever was reserved for labels that were
        NOT written is refunded automatically. Pricing: one look per 10s of
        footage (max 12), a ≤10s clip is 2 credits, the 120s maximum is 9.
      security:
        - bearerAuth: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                clipIds:
                  type: array
                  minItems: 1
                  maxItems: 200
                  items: { type: string }
                  description: explicit targets; omit to scan every unlabeled live clip on the key
                confirm:
                  type: boolean
                  enum: [true]
                  description: the literal true spends the quoted credits; anything else is a free quote
      responses:
        "200":
          description: |
            a quote (`confirm` absent — `charged: 0`, `message` says what a
            resend would cost), or the settled run (`confirm: true` —
            `charged` = credits actually delivered, `refunded` = the quoted
            ceiling minus that, `results` per clip). Zero targets is an empty
            quote with a plain-words `message`, never an error.
          content:
            application/json:
              schema:
                type: object
                required: [quote, charged]
                properties:
                  quote: { $ref: "#/components/schemas/ScanQuote" }
                  charged: { type: integer, description: "credits actually spent (0 on the quote pass)" }
                  refunded: { type: integer, description: "confirmed runs only: quoted ceiling minus delivered" }
                  message: { type: string, description: "quote pass / empty worklist only" }
                  results:
                    type: array
                    description: confirmed runs only — one entry per clip, labeled or refunded
                    items:
                      type: object
                      required: [clipId, labeled]
                      properties:
                        clipId: { type: string }
                        labeled: { type: boolean }
                        label: { type: string, description: "labeled: true only" }
                        credits: { type: integer, description: "labeled: true only — this clip's share of the charge" }
                        error: { type: string, description: "labeled: false only — this clip's share was refunded" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402":
          description: "`insufficient_credits` — the confirm pass reserves the whole quote up front"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503":
          description: no labeling model available (no box key and none stored for this key) — `render_failed`, nothing charged
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /images/search:
    get:
      tags: [images]
      operationId: searchImages
      summary: The image picker — four pools, independent degradation, provenance on every candidate
      description: |
        Key-authed, rate-gated (60/min). Searches up to four pools —
        `library` (the key's own labeled clips), `site` (images scraped from
        `siteUrl`), `pexels`, and `web` (Openverse + Wikimedia Commons) — and
        returns candidates tagged with where each came from and what that
        means. A pool that failed arrives as `{pool, ok: false, error}` beside
        the ones that answered; the `pools` envelope restates each pool's
        status so a picker UI can show "web search not configured" without
        digging.

        Every web-pool candidate carries `requiresAttestation: true`, ALWAYS:
        such an image renders only if the render request sends it with
        `source: "web"` and `attest: true` (see RenderRequest `assets`) —
        nothing found by web search ships unreviewed. `license` is plain words
        for the picker; `attribution`, when present, is the OFF-image credit
        line for the post's caption — it is metadata only and is never
        composited onto frames.
      security:
        - bearerAuth: []
      parameters:
        - name: q
          in: query
          required: true
          schema: { type: string, maxLength: 200 }
          description: what the images should show
        - name: pools
          in: query
          required: false
          schema: { type: string, example: "library,site,pexels,web" }
          description: comma list of pools to search (order preserved, duplicates collapsed); omit for all four
        - name: siteUrl
          in: query
          required: false
          schema: { type: string, maxLength: 2048 }
          description: http(s) URL for the `site` pool to scrape
      responses:
        "200":
          description: candidates from every pool that answered
          content:
            application/json:
              schema:
                type: object
                required: [query, candidates, pools, note]
                properties:
                  query: { type: string }
                  candidates:
                    type: array
                    items: { $ref: "#/components/schemas/ImageCandidate" }
                  pools:
                    type: array
                    description: per-pool status — ok pools carry their count, failed pools their plain-words error
                    items:
                      type: object
                      required: [pool, ok]
                      properties:
                        pool: { $ref: "#/components/schemas/ImagePool" }
                        ok: { type: boolean }
                        count: { type: integer, description: "ok: true only" }
                        available: { type: boolean, enum: [false], description: "ok: false only — present when the pool is not configured on this deployment (vs a transient failure)" }
                        error: { type: string, description: "ok: false only" }
                  note: { type: string, description: "restates the web-attestation rule in plain words" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /health:
    get:
      tags: [meta]
      operationId: health
      summary: Liveness + queue depth + worker/chain-data health
      responses:
        "200":
          description: healthy (or degraded with details)
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  queueDepth: { type: integer }
                  workers: { type: integer }
                  medianRenderSec: { type: number, description: "rolling median over last 20 video jobs" }
                  adapters:
                    type: object
                    additionalProperties: { type: string, enum: [ok, degraded, down] }
                    example: { rhc: ok, ethereum: down, solana: down }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: "sk_live_<base58>"
    supabaseAuth:
      type: http
      scheme: bearer
      bearerFormat: "Supabase access token"
      description: |
        Account surface only (GET /v1/auth/keys): the Supabase ACCESS TOKEN as
        the bearer — not an API key. Verified against Supabase live on every
        call; the box holds no sessions. On POST/DELETE auth-keys operations
        the same token travels in the request BODY instead (bodies and headers
        stay out of access logs; query strings do not).

  responses:
    BadRequest:
      description: malformed request — `invalid_request`, `unsupported_chain`, `unsupported_option`
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Unauthorized:
      description: missing/unknown/revoked API key — `invalid_key`
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    NotFound:
      description: "`template_not_found`, `job_not_found`, `kit_not_found`, `quote_not_found`, `pack_not_found`, `order_not_found`, `key_not_found` (account keys). Clip 404s carry `invalid_request` with the message \"no such clip on this key\" — deliberately byte-identical for \"not yours\" and \"gone\", so ids cannot be enumerated."
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    TokenNotEligible:
      description: token screened out — `token_not_eligible` (equity-category / tokenized-stock, security-like, risk-flagged, or unbucketable tokens cannot be promoted; deny-by-default)
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example:
            error:
              code: token_not_eligible
              message: "Stocks can't be promoted here — Slop Studio renders chain-native meme/community tokens only."
              details: { category: equity }
    RegionBlocked:
      description: HTTP 451 Unavailable For Legal Reasons — `region_blocked` (checkout is geo-blocked in the buyer's region; UK financial-promotions regime. Fail-open — unknown countries are never blocked)
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example:
            error:
              code: region_blocked
              message: "Slop Studio checkout isn't available in your region (financial-promotions rules)."
              details: { country: GB }
    RateLimited:
      description: over limit — `rate_limited`; includes Retry-After
      headers:
        Retry-After: { schema: { type: integer }, description: seconds }
        X-RateLimit-Limit: { schema: { type: integer } }
        X-RateLimit-Remaining: { schema: { type: integer } }
        X-RateLimit-Reset: { schema: { type: integer }, description: unix seconds }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example:
            error:
              code: rate_limited
              message: "Too many requests — slow down and retry."
              details: { retryAfterSec: 61 }

  schemas:
    Chain:
      type: string
      description: |
        Chain slug. All seven have registered adapters. The four EVM chains
        beyond ethereum (base, arbitrum, polygon, berachain) share one
        adapter driven by an EVM_CHAINS row — DexScreener for the tape,
        GeckoTerminal for the chart, Blockscout for holders where an
        instance exists (berachain has none, so holders stay null there).

        Templates differ: indexer-backed ones (board, pulse) are RHC-only.
        Clients should read per-template `chains` from /templates, not
        hardcode this list.
      enum: [rhc, ethereum, solana, base, arbitrum, polygon, berachain]

    Template:
      type: object
      required: [id, displayName, kind, priceUsd, priceCredits, freeTier, chains, output, options, previewUrl]
      properties:
        id:
          type: string
          description: stable template id (kebab-case) — see TEMPLATE-CATALOG.md
          enum: [monkeygun, monkeygun-story, chain-metrics, text-pack, spotlight-clip, spotlight-card, pulse-card, board-clip, battle-clip, teaser-clip, milestone-card, ai-meme-clip, odds-clip, resume-clip, token-overview, wallet-portfolio, monkeygun-slop, token-fable, daily-spotlight-pack, viral-spray-pack, insane-ai-pack, launcher-board-pack, agency-mix-pack]
        displayName: { type: string, example: "Token Spotlight — video" }
        description: { type: string, example: "9:16 hype clip: brand open → animated chart + live stats → CTA. Real chain data only." }
        kind: { type: string, enum: [video, image, text, pack] }
        dataKind:
          type: string
          # `story` binds to an argument the engine already found rather than to
          # a thing we fetch — its ref is a storyId from POST /v1/stories, so it
          # carries no chain and no address and its subject can be anything.
          enum: [token, prediction, person, wallet, story, chain]
          default: token
          description: >
            What this template binds its DATA to. `token` (the default) takes
            `chain` + `tokenAddress`/`tokenAddresses`. `prediction` takes a
            single `marketUrl` (Polymarket / Kalshi), lists no chains and is
            not screened — there is no token to screen. `person` takes a single
            public `linkedinUrl` PLUS a `person` approval object: research reads
            public pages only, so the buyer supplies and attests to the images
            the reel draws. Neither non-token kind is screened.
        priceUsd: { type: number, example: 3.00, description: "locked launch pricing — text-pack 0.50 · cards 1.00 · teaser-clip 2.00 · spotlight-clip 3.00 · board-clip/battle-clip 5.00" }
        priceCredits: { type: integer, example: 300 }
        freeTier: { type: boolean, example: false, description: "ALWAYS false — the free tier is gone; field retained so older clients fail closed" }
        chains:
          type: array
          items: { $ref: "#/components/schemas/Chain" }
          description: chains this template currently supports
        tokenCount:
          type: object
          description: how many token addresses the template takes
          properties:
            min: { type: integer, example: 1 }
            max: { type: integer, example: 1 }
        output:
          type: array
          description: available output formats (first = default; selected via options.aspect; text templates return an empty array)
          items:
            type: object
            properties:
              aspect: { type: string, enum: ["9:16", "16:9", "16:9-still"], example: "9:16" }
              width: { type: integer, example: 1080 }
              height: { type: integer, example: 1920 }
              approxDurationSec: { type: number, nullable: true, example: 11.7 }
        options:
          type: array
          description: per-template style params (see RenderOptions for the value semantics)
          items:
            type: object
            required: [name, type]
            properties:
              name: { type: string, example: theme }
              type: { type: string, enum: [string, number, boolean, enum] }
              values: { type: array, items: { type: string }, description: "for enum type — e.g. [classic, miami, toxic, heat]" }
              default: { description: "any", nullable: true }
        previewUrl:
          type: string
          description: static preview thumb/loop (rendered once at deploy, served from the studio host)
          example: "https://studio-api.tokenslop.fun/previews/spotlight-clip.jpg"

    RenderRequest:
      type: object
      required: [template]
      properties:
        template: { type: string, example: spotlight-clip }
        chain:
          $ref: "#/components/schemas/Chain"
          nullable: true
          description: >
            required for `dataKind: token` templates (every template except
            `odds-clip`); omit entirely for `dataKind: prediction`.
        marketUrl:
          type: string
          maxLength: 400
          description: >
            required for `dataKind: prediction` templates — the Polymarket or
            Kalshi market page URL. Mutually exclusive with chain/tokenAddress.
          example: "https://polymarket.com/event/kraken-ipo-in-2025"
        storyId:
          type: string
          pattern: "^(st_|arc_)[0-9a-f]{12}$"
          description: >
            required for `dataKind: story` templates (`monkeygun-story`) — an id
            from a previous POST /v1/stories response. The film argues that exact
            story: the beats are stored when discovered, not rebuilt when filmed,
            so the lines returned by /v1/stories are the lines the film speaks.
            Mutually exclusive with chain/tokenAddress. Scoped to the key that
            discovered it, and valid for 30 days.
          example: "arc_9f2c1d40a3b7"
        tokenAddress:
          type: string
          description: required for single-token templates (tokenCount.max = 1)
          example: "0x1234abcd1234abcd1234abcd1234abcd1234abcd"
        tokenAddresses:
          type: array
          items: { type: string }
          minItems: 2
          maxItems: 7
          description: required for multi-token templates (pulse-card, board-clip); order = display order
        options: { $ref: "#/components/schemas/RenderOptions" }
        links: { $ref: "#/components/schemas/Links" }
        assets: { $ref: "#/components/schemas/BuyerAssets" }
        clips:
          type: array
          maxItems: 12
          description: |
            KEY-AUTH ONLY, and only on the `monkeygun` and `monkeygun-story`
            lanes — the customer's OWN footage, by id, from the per-key clip
            library (POST /v1/clips). Ids, never URLs: a stored clip has no
            servable URL, and the rights attestation was taken per item at
            ingest rather than here.

            Each entry is a clip id string, or `{clipId, beatId}` to PIN the
            clip to one beat. Unpinned clips are cast automatically: the engine
            matches the clip's LABEL against what each beat is meant to show, so
            an UNLABELED CLIP IS NEVER CAST (label at ingest, or via
            PATCH /v1/clips/{clipId}, or run POST /v1/clips/scan).

            The customer's clip wins the b-roll slot; licensed stock is the
            fallback. A beat nothing matches keeps its drawn plate rather than
            receiving an unrelated clip.

            Naming a clip that is not this key's is `404 clip_not_found` —
            identical to an id that never existed, and never a silent drop.
            Sending this on a lane that cannot cut footage, or through a card
            checkout (which has no key, hence no library), is `400`.
          items:
            oneOf:
              - type: string
                pattern: "^clip_[0-9a-f]{16}$"
              - type: object
                required: [clipId]
                properties:
                  clipId:
                    type: string
                    pattern: "^clip_[0-9a-f]{16}$"
                  beatId:
                    type: string
                    pattern: "^[a-z0-9-]{1,32}$"
                    description: pin this clip to one beat instead of casting it
          example: ["clip_0123456789abcdef", { clipId: "clip_fedcba9876543210", beatId: "turn-1" }]
        useLibrary:
          type: boolean
          enum: [true]
          description: |
            Literal `true` only. Cast from EVERY labeled clip in this key's
            library, not just the ids named in `clips`. Same lane and auth rules
            as `clips`.
        sponsored:
          type: boolean
          description: |
            buyer/agent self-identifies as acting for the token team → the
            disclosure line becomes "sponsored by the $SYM team" (key-auth
            and retry renders; card orders carry it from checkout).
        quantity:
          type: integer
          minimum: 1
          maximum: 10
          default: 1
          description: |
            KEY-AUTH ONLY: render N copies (1–10) of this same request in ONE
            atomic debit of `priceCredits × quantity`; the `201` is the first
            copy's Job plus `jobIds` (all copies, array index = copy index).
            Each copy renders with a DIFFERENT deterministic variety-matrix
            seed (distinct theme/background pair per copy; voice/music rotate
            too) — buyer-set style options stay fixed across copies. Omit (or
            send 1) for a single render. NOT accepted with `payment.orderId`
            (quantity is fixed at checkout) or `payment.quoteId` (one quote =
            one render) → `invalid_request`.
        payment:
          type: object
          description: |
            payment proof — exactly one of `orderId` (paid card order whose
            job failed; one free retry) or `quoteId` (PARKED chain rail).
            Omit when using an API key.
          properties:
            orderId: { type: string, example: "ord_8a41f6d2c9e07b35" }
            quoteId: { type: string, example: "qt_9f2c81d0e4b74a51" }
            copyIndex:
              type: integer
              minimum: 0
              maximum: 9
              description: |
                MULTI-COPY order retry (with `orderId` only): the 0-based copy
                slot to re-render after that copy's job ended `failed`. The
                order stays `consumed`; the slot accepts a retry only while its
                current job is `failed` (`order_consumed` otherwise), and the
                new job reuses that copy's original variety seed. Omit for a
                single-copy order's free retry (use `orderId` alone).
        webhookUrl:
          type: string
          format: uri
          description: |
            OPTIONAL (Lane A stretch, keep the field reserved): POST {jobId,
            status, result} on terminal states. Signed `X-Slop-Signature:
            sha256=<hmac>` — HMAC key = the API key secret (key-auth jobs
            only; ignored otherwise).

    RenderOptions:
      type: object
      description: |
        Style knobs — NONE affect price in P1 (price is per template). Unknown
        keys → `unsupported_option`. Per-template applicability lives in
        /templates `options`.
      properties:
        theme:
          type: string
          enum: [classic, miami, toxic, heat]
          description: color theme; default = deterministic pick seeded by token address
        aspect:
          type: string
          enum: ["9:16", "1:1", "4:5", "16:9"]
          description: >
            output frame. Every 9:16 video template accepts 9:16 (default,
            1080×1920), 1:1 (1080×1080) and 4:5 (1080×1350) — the Meta feed
            placements. Same film, same plan, same price; only the frame
            changes. board-clip keeps its own vocabulary (9:16/16:9, where
            16:9 selects the wide composition rather than resizing).
        bgStyle:
          type: string
          enum: [orbs, starfield, stripes, vortex]
          description: spotlight-clip background; default seeded per token
        hookStyle:
          type: string
          enum: [brand, human]
          description: spotlight-clip cold open — brand bumper vs human-reaction footage; default brand for API renders (deterministic)
        headline:
          type: string
          maxLength: 48
          description: board-clip board title override (default "hottest on the chain"); profanity/impersonation-filtered server-side
        voiceover:
          type: boolean
          description: board-clip narration (default true; auto-false if VO budget/keys unavailable — never fails the job)
        windowLabel:
          type: string
          maxLength: 40
          description: pulse-card subtitle (default "most active right now")
        kitId:
          type: string
          description: |
            optional Brand Kit id from POST /v1/kits/generate — when present,
            renders prefer the kit's music bed and (on ai-meme-clip) mascot/soul
            stills over the token logo for fal conditioning.

    ProviderName:
      type: string
      description: bring-your-own-provider-API-key slot
      enum: [fal, elevenlabs, openrouter]

    ProviderCredentialStatus:
      type: object
      required: [fal, elevenlabs, openrouter]
      description: |
        one entry per provider — `null` (no stored credential, env
        fallback applies) or `{configured: true, hint}`. The plaintext
        secret is never returned.
      properties:
        fal: { $ref: "#/components/schemas/ProviderCredentialEntry" }
        elevenlabs: { $ref: "#/components/schemas/ProviderCredentialEntry" }
        openrouter: { $ref: "#/components/schemas/ProviderCredentialEntry" }

    ProviderCredentialEntry:
      nullable: true
      type: object
      required: [configured, hint]
      properties:
        configured: { type: boolean, enum: [true] }
        hint: { type: string, description: "last 4 characters of the stored secret", example: "aB3d" }

    ProviderCredentialsUpdate:
      type: object
      description: any of the three may be omitted; empty strings are ignored (no-op, not cleared)
      properties:
        fal: { type: string, description: "fal.ai API key" }
        elevenlabs: { type: string, description: "ElevenLabs API key (xi-api-key)" }
        openrouter: { type: string, description: "OpenRouter API key" }

    ProviderCredentialsDelete:
      type: object
      properties:
        providers:
          type: array
          items: { $ref: "#/components/schemas/ProviderName" }
          description: providers to clear; omit (or send an empty body) to clear ALL

    Job:
      type: object
      required: [jobId, status, progress, template, chain, watermarked, createdAt]
      properties:
        jobId: { type: string, example: "job_7c1f0a92b3d84e6f" }
        status:
          type: string
          description: |
            queued → fetching (snapshot + assets) → rendering → uploading →
            done | failed. No other values will ever be emitted.
          enum: [queued, fetching, rendering, uploading, done, failed]
        progress: { type: number, minimum: 0, maximum: 1, description: "render progress; 0 while queued/fetching, 1 at done" }
        queuePosition: { type: integer, nullable: true, description: "0 = next; null once started" }
        template: { type: string }
        chain:
          $ref: "#/components/schemas/Chain"
          nullable: true
          description: "null on `dataKind: prediction` jobs — they aren't bound to a chain"
        tokenAddress: { type: string, nullable: true }
        tokenAddresses: { type: array, items: { type: string }, nullable: true }
        marketUrl:
          type: string
          nullable: true
          description: "`dataKind: prediction` jobs only — the market this render was bought for"
        watermarked: { type: boolean, description: "always false — retained for compatibility (the watermarked free tier is gone)" }
        paidVia: { type: string, enum: [quote, credits, stripe], example: stripe }
        orderId: { type: string, nullable: true, description: "card-paid jobs: the funding checkout order" }
        copyIndex:
          type: integer
          minimum: 0
          maximum: 9
          nullable: true
          description: |
            multi-copy purchases: this job's 0-based slot within its order
            (card) or credits group. Present only on jobs that belong to a
            `quantity > 1` purchase; absent/null on single renders. Retry a
            failed copy with `POST /v1/render` `payment: {orderId, copyIndex}`.
        refunded: { type: boolean, description: "credits returned after a failed key-paid job" }
        createdAt: { type: string, format: date-time }
        startedAt: { type: string, format: date-time, nullable: true }
        finishedAt: { type: string, format: date-time, nullable: true }
        error:
          type: object
          nullable: true
          description: present only when status=failed
          properties:
            code: { type: string, enum: [token_not_found, token_risk_blocked, token_not_eligible, snapshot_failed, render_failed, upload_failed, internal] }
            message: { type: string }
        result:
          type: object
          nullable: true
          description: present only when status=done
          required: [mediaUrl, contentType, width, height, expiresAt]
          properties:
            mediaUrl: { type: string, description: presigned GET — expires; download promptly (text-pack → the JSON bundle) }
            thumbnailUrl: { type: string, nullable: true }
            contentType: { type: string, enum: [video/mp4, image/png, image/jpeg, application/json, text/plain] }
            width: { type: integer, nullable: true, description: null for text outputs }
            height: { type: integer, nullable: true, description: null for text outputs }
            durationSec: { type: number, nullable: true, description: null for stills/text }
            sizeBytes: { type: integer }
            expiresAt: { type: string, format: date-time, description: "+7 days" }
            caption:
              type: string
              description: suggested post text composed from the same snapshot (agents/share sheets can post it verbatim; never investment advice)
            snapshot:
              type: object
              description: the data the render was built from — clients show provenance ("numbers as of …")
              properties:
                symbol: { type: string }
                name: { type: string }
                priceUsd: { type: number, nullable: true }
                change24h: { type: number, nullable: true }
                volume24hUsd: { type: number, nullable: true }
                marketCapUsd: { type: number, nullable: true }
                holders: { type: integer, nullable: true }
                heat: { type: integer }
                asOf: { type: string, format: date-time }

    Links:
      type: object
      description: |
        optional official links, validated server-side and woven into the
        render (retail outro shows the 𝕏 handle; text-pack references
        both). Stored on the job.
      properties:
        website: { type: string, format: uri, maxLength: 200, example: "https://woofcoin.example" }
        x: { type: string, maxLength: 200, description: "𝕏 handle (@name) or x.com/twitter.com profile URL", example: "@woofcoin" }

    PaymentsCapability:
      type: object
      description: |
        which payment rails this deployment accepts — clients (web, MCP)
        MUST drive their payment UI/tooling from this object, never assume.
      required: [card, chainPay]
      properties:
        card: { type: boolean, example: true, description: "Stripe card checkout (`POST /v1/checkout`)" }
        chainPay: { type: boolean, example: false, description: "on-chain ETH quote rail (`/quotes` + `payment.quoteId`) — PARKED, false in production; when false the /quotes routes are not registered (404)" }

    VibesCapability:
      type: object
      description: Brand Kit / Add Vibes — present only when enabled on this deployment
      required: [enabled, priceUsd, priceCredits]
      properties:
        enabled: { type: boolean, example: true }
        priceUsd: { type: number, example: 1.5 }
        priceCredits: { type: integer, example: 150 }

    KitGenerateRequest:
      type: object
      required: [chain, tokenAddress, vibePrompt, attested]
      properties:
        chain: { $ref: "#/components/schemas/Chain" }
        tokenAddress: { type: string }
        vibePrompt:
          type: string
          maxLength: 500
          description: creative direction for stills + music (sanitized server-side)
        brief:
          type: string
          maxLength: 500
          nullable: true
        attested:
          type: boolean
          description: must be true — legal confirmation for generated promo assets
        imageCount:
          type: integer
          minimum: 1
          maximum: 3
          default: 3

    KitAsset:
      type: object
      required: [assetId, kind, role, url]
      properties:
        assetId: { type: string }
        kind: { type: string, enum: [image, music] }
        role: { type: string, enum: [mascot, soul, bed] }
        url: { type: string }
        meta: { type: object, additionalProperties: true }

    Kit:
      type: object
      required: [kitId, chain, tokenAddress, vibePrompt, attested, status, assets, createdAt]
      properties:
        kitId: { type: string }
        chain: { $ref: "#/components/schemas/Chain" }
        tokenAddress: { type: string }
        brief: { type: string, nullable: true }
        vibePrompt: { type: string }
        attested: { type: boolean }
        status: { type: string, enum: [generating, ready, failed] }
        orderId: { type: string, nullable: true }
        assets:
          type: array
          items: { $ref: "#/components/schemas/KitAsset" }
        error: { type: string, nullable: true, description: present when status=failed }
        createdAt: { type: string, format: date-time }
        readyAt: { type: string, format: date-time, nullable: true }

    CheckoutRequest:
      type: object
      required: [kind]
      properties:
        kind: { type: string, enum: [render, credits, vibes] }
        # kind: render —
        template: { type: string, description: "required when kind=render", example: spotlight-clip }
        chain: { $ref: "#/components/schemas/Chain" }
        tokenAddress: { type: string, nullable: true }
        tokenAddresses: { type: array, items: { type: string }, nullable: true }
        options: { $ref: "#/components/schemas/RenderOptions" }
        links: { $ref: "#/components/schemas/Links" }
        assets: { $ref: "#/components/schemas/BuyerAssets" }
        quantity:
          type: integer
          minimum: 1
          maximum: 10
          default: 1
          description: |
            kind=render: buy N copies (1–10) of this render in one checkout.
            The price is `priceUsd × quantity` and the Stripe receipt reads
            "Product × N". Every copy renders with a DIFFERENT deterministic
            variety-matrix seed (theme/background/voice/music rotate per copy —
            N distinct looks, "more shots at viral"); style options set here
            stay fixed across copies. Once paid, the order exposes `jobIds`
            (one per copy). Omit or send 1 for a single render.
        sponsored:
          type: boolean
          description: |
            buyer self-identifies as on/for the token team → the render's
            disclosure line becomes "sponsored by the $SYM team" (default:
            "paid promotional content · made with Slop Studio"). Recorded
            with the order as an attestation.
        # kind: credits (Bearer auth REQUIRED) —
        packId: { type: string, description: "one of /templates `credits.packs[].id`", example: "pack-15" }

    CheckoutOrder:
      type: object
      required: [orderId, kind, status, createdAt, expiresAt]
      properties:
        orderId: { type: string, example: "ord_8a41f6d2c9e07b35" }
        kind: { type: string, enum: [render, credits, vibes] }
        status:
          type: string
          description: |
            created (awaiting payment; checkoutUrl live) → paid (Stripe
            settled; also the reset state after a failed render job — retry
            via `POST /v1/render` `payment.orderId`) → consumed (render job
            enqueued / credits granted). `expired` = session lapsed unpaid;
            `closed` = closed/refunded via founder tooling.
          enum: [created, paid, consumed, expired, closed]
        checkoutUrl: { type: string, nullable: true, description: "Stripe Checkout URL — non-null only while status=created" }
        template: { type: string, nullable: true }
        chain:
          $ref: "#/components/schemas/Chain"
          nullable: true
          description: "absent on `dataKind: prediction` orders — they aren't bound to a chain"
        tokenAddress: { type: string, nullable: true }
        tokenAddresses: { type: array, items: { type: string }, nullable: true }
        marketUrl:
          type: string
          nullable: true
          description: "`dataKind: prediction` orders only — the market this order was paid for"
        packId: { type: string, nullable: true, description: "kind=credits only" }
        priceUsd: { type: number, description: "the ORDER total — `unit priceUsd × quantity` for render orders" }
        quantity: { type: integer, minimum: 1, maximum: 10, description: "kind=render only: copies bought in this checkout (1–10). Each renders with its own variety seed." }
        creditsOnSettle: { type: integer, nullable: true, description: "kind=credits only" }
        jobId: { type: string, nullable: true, description: "render orders: set once the paid job is enqueued (webhook-enqueue — buyers who close the tab still get media). For multi-copy orders this is `jobIds[0]` (copy 0), kept for older clients." }
        jobIds:
          type: array
          items: { type: string }
          nullable: true
          description: |
            render orders: every copy's current job, ordered by copy index
            (length = `quantity`), set once the paid order is enqueued. Poll
            each with `GET /v1/jobs/{jobId}`. `jobId` above == `jobIds[0]`.
        kitId:
          type: string
          nullable: true
          description: vibes orders — set once generation completes; poll GET /v1/kits/{kitId}
        createdAt: { type: string, format: date-time }
        expiresAt: { type: string, format: date-time, description: "checkout session expiry (~30 min); irrelevant once paid" }

    QuoteRequest:
      type: object
      required: [purpose]
      properties:
        purpose: { type: string, enum: [render, credits] }
        # purpose: render —
        template: { type: string, description: "required when purpose=render" }
        chain: { $ref: "#/components/schemas/Chain" }
        tokenAddress: { type: string, nullable: true }
        tokenAddresses: { type: array, items: { type: string }, nullable: true }
        # purpose: credits (Bearer auth required) —
        packId: { type: string, description: "one of /templates `credits.packs[].id`", example: "pack-50" }

    Quote:
      type: object
      required: [quoteId, purpose, status, priceUsd, payment, createdAt, expiresAt]
      properties:
        quoteId: { type: string }
        purpose: { type: string, enum: [render, credits] }
        status:
          type: string
          description: pending → paid → consumed (used by a render / credited); or expired
          enum: [pending, paid, consumed, expired]
        priceUsd: { type: number }
        priceCredits: { type: integer, nullable: true }
        template: { type: string, nullable: true }
        chain: { $ref: "#/components/schemas/Chain" }
        tokenAddress: { type: string, nullable: true }
        tokenAddresses: { type: array, items: { type: string }, nullable: true }
        creditsOnSettle: { type: integer, nullable: true, description: "purpose=credits only" }
        payment:
          type: object
          required: [network, chainId, asset, payTo, amountWei, minConfirmations]
          properties:
            network: { type: string, enum: [rhc], description: "payments land on RHC mainnet in P1 (ETH-mainnet later)" }
            chainId: { type: integer, enum: [4663] }
            asset: { type: string, enum: [ETH] }
            payTo: { type: string, description: "dedicated receiving EOA (STUDIO_PAYTO_ADDRESS) — never the fee vault, never a hot server key" }
            amountWei: { type: string, description: EXACT wei to send (string — exceeds JS safe int) }
            amountEth: { type: string, description: "same value, decimal ETH for display" }
            ethUsdAtQuote: { type: number }
            minConfirmations: { type: integer, example: 5 }
        txHash: { type: string, nullable: true, description: set once verification has seen the tx }
        confirmations: { type: integer, nullable: true }
        jobId: { type: string, nullable: true, description: set when consumed by a render }
        createdAt: { type: string, format: date-time }
        expiresAt: { type: string, format: date-time }

    CreditPacksInfo:
      type: object
      description: "locked launch packs: $15 → 1,650cr · $40 → 4,600cr · $100 → 12,500cr (volume bonus over the $0.01 base rate)"
      required: [usdPerCredit, packs]
      properties:
        usdPerCredit: { type: number, example: 0.01 }
        packs:
          type: array
          items:
            type: object
            required: [id, priceUsd, credits]
            properties:
              id: { type: string, example: pack-15 }
              priceUsd: { type: number, example: 15 }
              credits: { type: integer, example: 1650, description: includes volume bonus }

    BuyerAsset:
      description: |
        one buyer-supplied image — either a bare https URL string (defaults:
        kind product, use place) or the full object. Serves two jobs: PLACED
        (rendered directly into a beat) and/or CONDITIONED (handed to the
        generation model as the conditioning frame, so generated footage is
        built FROM the customer's own product). SVG is refused — it cannot be
        decoded into a conditioning frame.
      oneOf:
        - type: string
          description: "https URL shorthand — equivalent to {url, kind: product, use: place}"
        - type: object
          required: [url]
          properties:
            url: { type: string, description: "https only, ≤2048 chars, raster image (no SVG)", example: "https://cdn.example/product.png" }
            kind:
              type: string
              enum: [product, logo, scene]
              default: product
              description: what it IS — decides which beats will accept it
            use:
              type: string
              enum: [place, condition, both]
              default: place
              description: what it's FOR — placed in frame, used as a generation seed, or both
            beatId:
              type: string
              pattern: "^[A-Za-z0-9-]{1,32}$"
              description: pin this asset to one beat (e.g. "hero", "cta") instead of filling roles in order
            label:
              type: string
              maxLength: 80
              description: on-screen caption; validated against the same banned-claims table as copy
            source:
              $ref: "#/components/schemas/ImagePool"
              description: which picker pool found it (GET /v1/images/search) — provenance the web-attestation rule enforces
            attest:
              type: boolean
              enum: [true]
              description: |
                per-image review attestation — REQUIRED (the literal true)
                when `source: "web"`: "I looked at THIS found image and I
                clear it". Never pre-ticked, never batch-defaulted, never
                inferred from rightsConfirmed. Missing on a web-pool image →
                400 `rights_not_confirmed`.

    BuyerAssets:
      type: object
      description: |
        buyer-supplied images, accepted on ANY lane (top-level `assets` on
        render and render-checkout bodies). No upload infrastructure — images
        are https URLs the buyer already hosts. Validated BEFORE any money
        moves: a payload without the rights attestation is a 400 and never a
        charged job. Duplicate URLs are refused.
      required: [assets, rightsConfirmed]
      properties:
        assets:
          type: array
          minItems: 1
          maxItems: 12
          items: { $ref: "#/components/schemas/BuyerAsset" }
        rightsConfirmed:
          type: boolean
          enum: [true]
          description: |
            the buyer's explicit "these are mine to use, including for
            generated footage derived from them" — the literal true only;
            "true", 1 and truthy objects are refused
            (`rights_not_confirmed`).

    ImagePool:
      type: string
      description: |
        where a picker image comes from. `library` = the key's own stored
        clips (searched by label); `site` = scraped from the caller's own
        siteUrl; `pexels` = the Pexels photo API; `web` = zero-signup open
        APIs (Openverse + Wikimedia Commons) — web-pool images ALWAYS require
        a per-image attestation to render.
      enum: [library, site, pexels, web]

    ImageCandidate:
      type: object
      required: [pool, url, license, requiresAttestation]
      properties:
        pool: { $ref: "#/components/schemas/ImagePool" }
        url: { type: string }
        thumbUrl: { type: string }
        width: { type: integer }
        height: { type: integer }
        license: { type: string, description: "plain words, per pool/image — a human reads it in the picker", example: "CC-BY 4.0 (metadata from Openverse — verify before commercial use)" }
        attribution:
          type: string
          description: |
            OFF-image credit for licenses that require one ("Photo: Jane Doe,
            CC-BY 4.0"). METADATA ONLY, by founder rule: it rides the
            candidate JSON for the picker tile and the post's
            caption/description — the engine never overlays, watermarks, or
            composites it onto rendered frames. Absent when the license needs
            no credit (CC0, public domain, Pexels).
        requiresAttestation:
          type: boolean
          description: |
            the render-side gate — true for the web pool ALWAYS (community
            metadata can be mislabeled; a declared license never waives it),
            false everywhere else. A true candidate renders only when sent as
            an asset with `source: "web"` and `attest: true`.
        clipId: { type: string, description: "library pool only: the stored clip this refers to, castable by clipId" }
        label: { type: string, description: "library pool only: the customer's own label — why it matched" }

    ClipIngestItem:
      type: object
      required: [youtubeUrl, attest]
      properties:
        youtubeUrl:
          type: string
          description: exact video page URL from an allowed host (youtube.com, youtu.be, vimeo.com, player.vimeo.com) — never a homepage or channel
          example: "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
        startSec: { type: number, minimum: 0, description: "default 0" }
        endSec: { type: number, description: "default startSec + 15; span capped at 120 seconds" }
        attest:
          type: boolean
          enum: [true]
          description: |
            the literal true — you confirm you own or are licensed to use
            this footage, including the span we cut from it. Checked BEFORE
            the URL is parsed; nothing is downloaded without it. In a batch,
            every item carries its OWN attest.
        label:
          type: string
          maxLength: 200
          description: 'optional, free at ingest: one line — what it shows / when to use it (banned-claims linted)'

    ClipIngested:
      type: object
      required: [clipId, seconds, expiresAt]
      properties:
        clipId: { type: string, example: "clip_9f2c81d0e4b7" }
        seconds: { type: number, description: how long the stored footage runs }
        expiresAt: { type: string, format: date-time, description: when the stored copy lapses (tier retention) }
        label: { type: string, nullable: true, description: the label it carries — null when none }

    Clip:
      type: object
      required: [clipId, videoId, sourceUrl, startSec, endSec, seconds, createdAt, expiresAt]
      properties:
        clipId: { type: string }
        videoId: { type: string }
        sourceUrl: { type: string }
        startSec: { type: number }
        endSec: { type: number }
        seconds: { type: number }
        fps: { type: number, nullable: true }
        width: { type: integer, nullable: true }
        height: { type: integer, nullable: true }
        bytes: { type: integer, nullable: true }
        createdAt: { type: string, format: date-time }
        expiresAt: { type: string, format: date-time }
        label: { type: string, nullable: true, description: "one line: what it shows / when to use it — null until someone writes it" }
        labelSource:
          type: string
          nullable: true
          enum: [customer, scan, null]
          description: who wrote the label — the customer (free) or the paid scan pass; customer words always overwrite a scan's; null while unlabeled

    ScanQuote:
      type: object
      description: |
        the scan's price, per clip, before anything runs. The total is the
        SUM of per-clip credits (each floored at 1) — that is what lets a
        partial failure refund exactly the clips whose labels were not
        written.
      required: [credits, costUsd, clips]
      properties:
        credits: { type: integer, description: the ceiling a confirmed run reserves }
        costUsd: { type: number }
        clips:
          type: array
          items:
            type: object
            required: [clipId, seconds, frames, credits]
            properties:
              clipId: { type: string }
              seconds: { type: number }
              frames: { type: integer, description: "one look per 10s of footage, clamped to [1, 12]" }
              credits: { type: integer }

    AccountKey:
      type: object
      description: one row of the profile page's key list — never a secret
      required: [keyId, label, createdAt, lastUsedAt, masked]
      properties:
        keyId: { type: string }
        label: { type: string, nullable: true }
        createdAt: { type: string, format: date-time }
        lastUsedAt: { type: string, format: date-time, nullable: true, description: null until the key authenticates its first request }
        masked:
          type: string
          nullable: true
          description: |
            first 8 + "…" + last 4 of the secret, derived from the vault so
            buyers can recognize which key is which — null for keys that
            predate the vault (roll the key to fix that).
          example: "sk_live_…4uQ9"

    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              description: stable machine-readable code — the FULL set; clients switch on these
              enum:
                - invalid_request
                - unsupported_chain
                - unsupported_option
                - invalid_key
                - template_not_found
                - job_not_found
                - kit_not_found
                - quote_not_found
                - pack_not_found
                - order_not_found
                - key_not_found
                - not_revealable
                - accounts_unconfigured
                - token_not_found
                - token_risk_blocked
                - token_not_eligible
                - region_blocked
                - rights_not_confirmed
                - payment_required
                - insufficient_credits
                - order_unpaid
                - order_consumed
                - order_mismatch
                - quote_unpaid
                - quote_expired
                - quote_consumed
                - quote_mismatch
                - tx_not_found
                - tx_reverted
                - wrong_recipient
                - amount_mismatch
                - tx_replayed
                - clip_quota_exceeded
                - rate_limited
                - render_failed
                - site_unreadable
                - internal
            message: { type: string, description: human-readable; safe to show users }
            details: { type: object, additionalProperties: true, nullable: true }
