openapi: 3.1.0
info:
  title: StackFill API
  version: "2026-07-15"
  description: |
    StackFill lets print shops expose product personalization through hosted
    links, browser embeds, and server-to-server API workflows.
  contact:
    name: StackFill Support
    url: https://stackfill.com/contact/
  license:
    name: Proprietary
servers:
  - url: https://api.stackfill.com/v1
    description: Production
  - url: http://localhost:8788/v1
    description: Local development
tags:
  - name: templates
  - name: product_mappings
  - name: embed_applications
  - name: embed_sessions
  - name: embed_fills
  - name: fills
  - name: renders
  - name: webhooks
x-stackfill-stability:
  stable: Covered by v1 compatibility promise.
  beta: May change with 30 days notice.
security:
  - bearerAuth: []
paths:
  /templates:
    get:
      operationId: listTemplates
      tags: [templates]
      summary: List templates
      description: Lists non-archived templates available to the authenticated team.
      x-stability: stable
      responses:
        "200":
          description: Template list.
          headers:
            X-Request-Id: { $ref: "#/components/headers/XRequestId" }
            X-RateLimit-Limit: { $ref: "#/components/headers/XRateLimitLimit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/XRateLimitRemaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/XRateLimitReset" }
          content:
            application/json:
              schema:
                type: object
                required: [data, has_more]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Template" }
                  has_more: { type: boolean, example: false }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/ServerError" }
    post:
      operationId: createTemplate
      tags: [templates]
      summary: Upload and ingest a PDF template
      description: Uploads a finished print PDF and creates a draft template plus the first scene revision.
      x-stability: beta
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [pdf]
              properties:
                pdf:
                  type: string
                  format: binary
                  description: PDF file up to 50 MB.
                title:
                  type: string
                  example: Baptist business card
      responses:
        "200":
          description: Template created or deduplicated.
          content:
            application/json:
              schema:
                type: object
                required: [template]
                properties:
                  template: { $ref: "#/components/schemas/Template" }
                  scene: { $ref: "#/components/schemas/SceneSummary" }
                  deduplicated: { type: boolean, example: false }
                  restored: { type: boolean, example: false }
                  refreshed: { type: boolean, example: false }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "413": { $ref: "#/components/responses/BadRequest" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/ServerError" }
  /templates/{id}:
    get:
      operationId: getTemplate
      tags: [templates]
      summary: Retrieve a template
      description: Returns a template and the latest scene JSON for the authenticated team.
      x-stability: stable
      parameters:
        - $ref: "#/components/parameters/id"
      responses:
        "200":
          description: Template and scene.
          content:
            application/json:
              schema:
                type: object
                required: [template, revision]
                properties:
                  template: { $ref: "#/components/schemas/Template" }
                  scene: { $ref: "#/components/schemas/SceneSummary" }
                  revision: { type: integer, example: 4 }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/ServerError" }
  /templates/{id}/fields:
    get:
      operationId: listTemplateFields
      tags: [templates]
      summary: List the fillable fields for a template
      description: |
        Returns the public-shaped field descriptors for the template's latest
        scene revision: id, label, type ("text" | "selection" | "image"),
        default_value, max_length. For selection fields, the resolved
        selection_set_id and options array. For image fields, the team's
        catalogue of pre-approved image assets. This is the exact shape the
        hosted /fill/<slug> page renders against — calling this then POSTing
        to /v1/fills will accept the same keys.
      x-stability: beta
      parameters:
        - $ref: "#/components/parameters/id"
      responses:
        "200":
          description: Field descriptors.
          content:
            application/json:
              schema:
                type: object
                required: [template_id, scene_revision, data, has_more]
                properties:
                  template_id: { type: string }
                  scene_revision: { type: integer, example: 4 }
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Field" }
                  has_more: { type: boolean }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/ServerError" }
  /templates/{id}/publish:
    post:
      operationId: publishTemplate
      tags: [templates]
      summary: Publish a template (draft → live)
      description: |
        Flips a template from draft (or paused) to live. Required before a
        hosted /fill/<slug> link will resolve and before /v1/fills will
        accept fills against the template. Idempotent — re-publishing a
        live template returns 200. Archived templates must be restored
        before they can be published.
      x-stability: beta
      parameters:
        - $ref: "#/components/parameters/id"
      responses:
        "200":
          description: Template was published (or was already live).
          content:
            application/json:
              schema:
                type: object
                required: [template, revision]
                properties:
                  template: { $ref: "#/components/schemas/Template" }
                  revision: { type: integer, example: 4 }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: Template is archived or has no saved scene.
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/ServerError" }
  /renders:
    get:
      operationId: listRenders
      tags: [renders]
      summary: List renders
      description: Lists recent preview and final render rows for the authenticated team. Use this ledger to reconcile saved fills, storefront orders, generated PDFs, and preflight status. Filter by external_order_id / external_cart_id to resolve one order's production PDFs.
      x-stability: beta
      parameters:
        - in: query
          name: external_order_id
          description: The storefront's order reference — resolves an order's production renders.
          schema: { type: string }
        - in: query
          name: external_cart_id
          description: The storefront's cart reference.
          schema: { type: string }
      responses:
        "200":
          description: Render list.
          headers:
            X-Request-Id: { $ref: "#/components/headers/XRequestId" }
            X-RateLimit-Limit: { $ref: "#/components/headers/XRateLimitLimit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/XRateLimitRemaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/XRateLimitReset" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ListEnvelope" }
              examples:
                recent:
                  value:
                    data:
                      - id: rnd_123
                        render_id: rnd_123
                        generation_id: rnd_123
                        template_id: tpl_123
                        template_title: FIU Baptist Business Card
                        project_name: Demo healthcare project
                        fill_id: fill_123
                        customer_email: jordan@example.com
                        kind: final
                        status: succeeded
                        signed_url: https://stackfill-assets.example/renders/rnd_123.pdf?sig=...
                        preflight_status: pass
                        size_bytes: 842116
                        created_at: "2026-05-23T15:41:00Z"
                    has_more: false
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/ServerError" }
    post:
      operationId: createRender
      tags: [renders]
      summary: Create a final PDF render
      description: |
        Creates or queues a final render for a template scene revision. Use
        POST /previews for secret-key template previews or
        GET /embed/fills/{id}/preview for a browser-authenticated live fill PNG.
      x-stability: beta
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RenderCreate" }
            examples:
              final:
                value:
                  template_id: tpl_123
                  scene_revision: 1
                  kind: final
                  values:
                    fld_name: Jordan Tate
      responses:
        "200":
          description: Render succeeded.
          content:
            application/json:
              schema:
                type: object
                required: [render]
                properties:
                  render: { $ref: "#/components/schemas/Render" }
              examples:
                succeeded:
                  value:
                    render:
                      id: rnd_123
                      render_id: rnd_123
                      generation_id: rnd_123
                      template_id: tpl_123
                      kind: final
                      status: succeeded
                      signed_url: https://stackfill-assets.example/renders/rnd_123.pdf?sig=...
                      preflight_status: pass
                      size_bytes: 842116
                      created_at: "2026-05-23T15:41:00Z"
        "202":
          description: Render queued because the renderer service is not configured or not immediately available.
          content:
            application/json:
              schema:
                type: object
                required: [render]
                properties:
                  render: { $ref: "#/components/schemas/Render" }
              examples:
                queued:
                  value:
                    render:
                      id: rnd_queued
                      render_id: rnd_queued
                      generation_id: rnd_queued
                      template_id: tpl_123
                      kind: final
                      status: queued
                      signed_url: null
                      preflight_status: pending
                      preflight_notes: "{\"message\":\"Print-grade renderer not configured.\"}"
                      created_at: "2026-05-23T15:41:00Z"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/ServerError" }
  /renders/{id}:
    get:
      operationId: getRender
      tags: [renders]
      summary: Retrieve a render
      description: Returns render status and a short-lived signed URL when the PDF is stored.
      x-stability: stable
      parameters:
        - $ref: "#/components/parameters/id"
      responses:
        "200":
          description: Render details.
          content:
            application/json:
              schema:
                type: object
                required: [render]
                properties:
                  render: { $ref: "#/components/schemas/Render" }
              examples:
                succeeded:
                  value:
                    render:
                      id: rnd_123
                      render_id: rnd_123
                      generation_id: rnd_123
                      template_id: tpl_123
                      template_title: FIU Baptist Business Card
                      fill_id: fill_123
                      kind: final
                      status: succeeded
                      signed_url: https://stackfill-assets.example/renders/rnd_123.pdf?sig=...
                      preflight_status: pass
                      size_bytes: 842116
                      created_at: "2026-05-23T15:41:00Z"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/ServerError" }
  /renders/{id}/download:
    get:
      operationId: downloadRender
      tags: [renders]
      summary: Stream the rendered PDF
      description: |
        Streams the PDF bytes for a completed render with
        Content-Type: application/pdf and a Content-Disposition that
        defaults to attachment. Pass ?filename=foo.pdf to control the
        suggested filename (sanitized to a safe character set). Pass
        ?inline=1 to switch to Content-Disposition: inline so the
        browser previews in-tab. Returns 409 render_not_ready when
        the render hasn't completed yet — poll /v1/renders/{id} until
        status="succeeded" before calling /download.
      x-stability: beta
      parameters:
        - $ref: "#/components/parameters/id"
        - in: query
          name: filename
          required: false
          description: Suggested download filename.
          schema: { type: string, maxLength: 120 }
        - in: query
          name: inline
          required: false
          description: Set to "1" to switch the disposition from attachment to inline.
          schema: { type: string, enum: ["1"] }
      responses:
        "200":
          description: PDF bytes.
          content:
            application/pdf:
              schema: { type: string, format: binary }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: Render is not ready (still queued or stored in the pending state).
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/ServerError" }
  /renders/{id}/retry:
    post:
      operationId: retryRender
      tags: [renders]
      summary: Retry a stuck or failed render
      description: |
        Re-runs a render that's stuck queued (pdf_key starts with
        "pending:") or whose preflight status is fail/warn. Idempotent
        on completed renders: a render with preflight=pass and a stored
        PDF returns 200 with idempotent_replay=true. The render_id does
        NOT change — downstream references stay stable. Useful after a
        renderer outage or a font-license fix.
      x-stability: beta
      parameters:
        - $ref: "#/components/parameters/id"
      responses:
        "200":
          description: Render either re-ran or was already complete.
          content:
            application/json:
              schema:
                type: object
                required: [render]
                properties:
                  render: { $ref: "#/components/schemas/Render" }
                  idempotent_replay: { type: boolean }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/ServerError" }
        "502":
          description: Print-grade renderer was unreachable or returned an error.
  /previews:
    post:
      operationId: createPreview
      tags: [renders]
      summary: Rasterize a template to a PNG
      description: |
        Renders a template (with optional field values) to a PNG via the same
        Ghostscript engine that produces the press PDF, so a product-listing
        thumbnail can't drift from what actually prints. Counted as a preview —
        it does NOT consume render_limit_monthly. Returns image/png.
      x-stability: beta
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [template_id]
              properties:
                template_id: { type: string }
                values:
                  type: object
                  additionalProperties: { type: string }
                  description: |
                    Field key → value, applied before rastering. Use the
                    fields[].id returned by GET /templates/{id}/fields.
                    Human labels remain accepted for compatibility.
                page: { type: integer, minimum: 0, default: 0, description: 0-indexed page (front=0). }
                dpi: { type: integer, minimum: 72, maximum: 300, default: 192 }
                colors:
                  type: array
                  description: |
                    Optional storefront ink palette; the preview recolors to it.
                    Screen approximation only (the press PDF uses spot
                    separations). index 1 is the primary ink.
                  items:
                    type: object
                    properties:
                      index: { type: integer, minimum: 1 }
                      key: { type: string }
                      name: { type: string }
                      hex: { type: string, description: "6-digit hex, e.g. #1f1d1a" }
                assignments:
                  type: object
                  additionalProperties: { type: integer }
                  description: |
                    fields[].id (or legacy field label) → color index.
                    Omitted fields use index 1.
                substrate:
                  type: object
                  description: |
                    Optional paper stock the design composites ON — re-grounds
                    the PREVIEW only, never a printed separation (§10a).
                  properties:
                    key: { type: string }
                    name: { type: string }
                    hex: { type: string, description: "6-digit hex of the stock color" }
      responses:
        "200":
          description: PNG raster.
          content:
            image/png:
              schema: { type: string, format: binary }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422":
          description: Invalid colors, assignments, or substrate.
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/ServerError" }
        "502":
          description: Raster renderer was unreachable or returned an error.
        "503":
          description: Raster renderer is not configured.
  /fills:
    get:
      operationId: listFills
      tags: [fills]
      summary: List fills
      description: |
        Paginated list of fills the API key's team owns, joined to the
        templates table for title/slug. Filterable by template_id, status,
        source, external_order_id, external_cart_id, and created_at floor.
        Cursor-by-id descending — pass the last row's `id` from the previous
        page as ?starting_after=<id>.
      x-stability: beta
      parameters:
        - in: query
          name: template_id
          schema: { type: string }
        - in: query
          name: status
          schema: { type: string, enum: [open, finalized, expired] }
        - in: query
          name: source
          schema: { type: string, enum: [hosted, embed, api] }
        - in: query
          name: external_order_id
          description: The storefront's order reference — resolves an order's fills.
          schema: { type: string }
        - in: query
          name: external_cart_id
          description: The storefront's cart reference.
          schema: { type: string }
        - in: query
          name: since
          description: RFC 3339 lower bound on created_at.
          schema: { type: string, format: date-time }
        - in: query
          name: starting_after
          description: Cursor — `id` of the last item from the previous page.
          schema: { type: string }
        - in: query
          name: limit
          schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
      responses:
        "200":
          description: Fill list.
          content:
            application/json:
              schema:
                type: object
                required: [data, has_more]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Fill" }
                  has_more: { type: boolean }
                  next_starting_after: { type: string, nullable: true }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/ServerError" }
  /fills/{id}:
    get:
      operationId: getFill
      tags: [fills]
      summary: Retrieve a fill
      description: Returns the fill row plus the most recent associated render (if any).
      x-stability: beta
      parameters:
        - $ref: "#/components/parameters/id"
      responses:
        "200":
          description: Fill detail.
          content:
            application/json:
              schema:
                type: object
                required: [fill]
                properties:
                  fill: { $ref: "#/components/schemas/Fill" }
                  last_render:
                    nullable: true
                    allOf: [{ $ref: "#/components/schemas/Render" }]
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/ServerError" }
  /fills/{id}/finalize:
    post:
      operationId: finalizeFill
      tags: [fills]
      summary: Finalize a fill and trigger the print render
      description: |
        Marks an open fill as finalized and kicks the print-grade render.
        Body is OPTIONAL — supply { values } to patch the customer-
        submitted field values, and/or { customer_email, customer_name }
        to capture contact info at checkout time. Idempotent on already-
        finalized fills (returns existing state + last render with
        idempotent_replay=true).
      x-stability: beta
      parameters:
        - $ref: "#/components/parameters/id"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                values:
                  type: object
                  additionalProperties: { type: string }
                  description: Sparse patch — supplied keys overwrite, others left alone.
                customer_email: { type: string, format: email }
                customer_name:  { type: string, maxLength: 200 }
      responses:
        "200":
          description: Fill finalized and render produced.
          content:
            application/json:
              schema:
                type: object
                required: [fill, render]
                properties:
                  fill: { $ref: "#/components/schemas/Fill" }
                  render: { $ref: "#/components/schemas/Render" }
                  idempotent_replay: { type: boolean }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: Fill has already expired and cannot be finalized.
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/ServerError" }
        "502":
          description: Print-grade renderer was unreachable or returned an error.
  /product_mappings:
    get:
      operationId: listProductMappings
      tags: [product_mappings]
      summary: List product mappings
      description: Lists mappings from external product identifiers to StackFill templates.
      x-stability: stable
      parameters:
        - $ref: "#/components/parameters/limit"
        - name: external_product_id
          in: query
          required: false
          schema: { type: string }
      responses:
        "200":
          description: Product mapping list.
          content:
            application/json:
              schema:
                type: object
                required: [data, has_more]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/ProductMapping" }
                  has_more: { type: boolean, example: false }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/ServerError" }
    post:
      operationId: createProductMapping
      tags: [product_mappings]
      summary: Create a product mapping
      description: Maps an external product or variant to a StackFill template.
      x-stability: stable
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ProductMappingCreate" }
      responses:
        "201":
          description: Product mapping created.
          content:
            application/json:
              schema:
                type: object
                required: [product_mapping]
                properties:
                  product_mapping: { $ref: "#/components/schemas/ProductMapping" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/ServerError" }
  /product_mappings/{id}:
    get:
      operationId: getProductMapping
      tags: [product_mappings]
      summary: Retrieve a product mapping
      x-stability: stable
      parameters:
        - $ref: "#/components/parameters/id"
      responses:
        "200":
          description: Product mapping.
          content:
            application/json:
              schema:
                type: object
                required: [product_mapping]
                properties:
                  product_mapping: { $ref: "#/components/schemas/ProductMapping" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/ServerError" }
    patch:
      operationId: updateProductMapping
      tags: [product_mappings]
      summary: Update a product mapping
      x-stability: stable
      parameters:
        - $ref: "#/components/parameters/id"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ProductMappingUpdate" }
      responses:
        "200":
          description: Product mapping updated.
          content:
            application/json:
              schema:
                type: object
                required: [product_mapping]
                properties:
                  product_mapping: { $ref: "#/components/schemas/ProductMapping" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/ServerError" }
    delete:
      operationId: deleteProductMapping
      tags: [product_mappings]
      summary: Archive a product mapping
      x-stability: stable
      parameters:
        - $ref: "#/components/parameters/id"
      responses:
        "200":
          description: Product mapping archived.
          content:
            application/json:
              schema:
                type: object
                required: [ok]
                properties:
                  ok: { type: boolean, example: true }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/ServerError" }
  /product_mappings/resolve:
    post:
      operationId: resolveProductMapping
      tags: [product_mappings]
      summary: Resolve a product mapping from a publishable key
      description: Browser-safe endpoint used by storefronts and the widget before creating guest sessions.
      x-stability: stable
      security:
        - publishableKey: []
        - {}
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ProductMappingResolveRequest" }
      responses:
        "200":
          description: Resolved mapping.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ProductMappingResolveResponse" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/ServerError" }
  /embed/applications:
    get:
      operationId: listEmbedApplications
      tags: [embed_applications]
      summary: List embed applications
      x-stability: stable
      responses:
        "200":
          description: Embed application list.
          content:
            application/json:
              schema:
                type: object
                required: [data, has_more]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/EmbedApplication" }
                  has_more: { type: boolean, example: false }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/ServerError" }
    post:
      operationId: createEmbedApplication
      tags: [embed_applications]
      summary: Create an embed application
      description: Creates an application with allowed origins and optionally creates a publishable key shown once.
      x-stability: stable
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/EmbedApplicationCreate" }
      responses:
        "201":
          description: Embed application created.
          content:
            application/json:
              schema:
                type: object
                required: [embed_application]
                properties:
                  embed_application: { $ref: "#/components/schemas/EmbedApplication" }
                  publishable_key:
                    type: string
                    description: Revealed once if create_publishable_key is true.
                    example: stackfill_live_pk_REPLACE_ME
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/ServerError" }
  /embed/applications/{id}:
    get:
      operationId: getEmbedApplication
      tags: [embed_applications]
      summary: Retrieve an embed application
      x-stability: stable
      parameters:
        - $ref: "#/components/parameters/id"
      responses:
        "200":
          description: Embed application.
          content:
            application/json:
              schema:
                type: object
                required: [embed_application]
                properties:
                  embed_application: { $ref: "#/components/schemas/EmbedApplication" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/ServerError" }
    patch:
      operationId: updateEmbedApplication
      tags: [embed_applications]
      summary: Update an embed application
      x-stability: stable
      parameters:
        - $ref: "#/components/parameters/id"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/EmbedApplicationUpdate" }
      responses:
        "200":
          description: Embed application updated.
          content:
            application/json:
              schema:
                type: object
                required: [embed_application]
                properties:
                  embed_application: { $ref: "#/components/schemas/EmbedApplication" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/ServerError" }
    delete:
      operationId: deleteEmbedApplication
      tags: [embed_applications]
      summary: Archive an embed application
      x-stability: stable
      parameters:
        - $ref: "#/components/parameters/id"
      responses:
        "200":
          description: Embed application archived.
          content:
            application/json:
              schema:
                type: object
                required: [ok]
                properties:
                  ok: { type: boolean, example: true }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/ServerError" }
  /embed/sessions:
    get:
      operationId: listEmbedSessions
      tags: [embed_sessions]
      summary: List embed sessions
      x-stability: stable
      parameters:
        - $ref: "#/components/parameters/limit"
      responses:
        "200":
          description: Embed session list.
          content:
            application/json:
              schema:
                type: object
                required: [data, has_more]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/EmbedSession" }
                  has_more: { type: boolean, example: false }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/ServerError" }
    post:
      operationId: createEmbedSession
      tags: [embed_sessions]
      summary: Create a server-minted embed session
      x-stability: stable
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/EmbedSessionCreate" }
      responses:
        "201":
          description: Embed session created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EmbedSessionCreateResponse" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/ServerError" }
  /embed/sessions/guest:
    post:
      operationId: createGuestEmbedSession
      tags: [embed_sessions]
      summary: Create a browser guest embed session
      description: Browser-safe session creation for mapped products. The header form of the publishable key is preferred.
      x-stability: stable
      security:
        - publishableKey: []
        - {}
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/EmbedSessionGuestCreate" }
      responses:
        "201":
          description: Embed session created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EmbedSessionCreateResponse" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/ServerError" }
  /embed/fills:
    post:
      operationId: createEmbedFill
      tags: [embed_fills]
      summary: Create or resume a fill from an embed session
      description: |
        Browser-authenticated by the embed-session client secret
        (X-StackFill-Client-Secret header or ?client_secret=). Idempotent:
        resumes the session's existing fill (200) or creates one (201). Body
        carries the session ref plus optional values / colors / assignments /
        substrate.
      x-stability: beta
      security:
        - embedSessionSecret: []
        - embedSessionSecretQuery: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [embed_session]
              properties:
                embed_session: { type: string, description: The embed session id. }
                values:
                  type: object
                  additionalProperties: { type: string }
                  description: Use fields[].id keys; field labels remain accepted for compatibility.
                colors: { type: array, items: { type: object } }
                assignments:
                  type: object
                  additionalProperties: { type: integer }
                  description: Use fields[].id keys; field labels remain accepted for compatibility.
                substrate: { type: object }
      responses:
        "200":
          description: Existing fill updated.
        "201":
          description: Fill created.
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "410":
          description: The embed session expired or is closed.
        "422":
          description: Invalid colors, assignments, or substrate.
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/ServerError" }
  /embed/fills/{id}:
    patch:
      operationId: patchEmbedFill
      tags: [embed_fills]
      summary: Update an embed fill's values / palette / substrate
      description: |
        {id} is the fill id; the session is resolved from the fill and
        re-authenticated with the client secret. Partial + idempotent: the
        values group and the color group update independently.
      x-stability: beta
      security:
        - embedSessionSecret: []
        - embedSessionSecretQuery: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                values: { type: object, additionalProperties: { type: string } }
                colors: { type: array, items: { type: object } }
                assignments:
                  type: object
                  additionalProperties: { type: integer }
                  description: Use fields[].id keys; field labels remain accepted for compatibility.
                substrate: { type: object }
      responses:
        "200":
          description: Fill updated.
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "410":
          description: The embed session expired or is closed.
        "422":
          description: Invalid colors, assignments, or substrate.
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/ServerError" }
  /embed/fills/{id}/preview:
    get:
      operationId: getEmbedFillPreview
      tags: [embed_fills]
      summary: Render the current embed fill as a PNG
      description: |
        Browser-callable live preview of the fill's latest persisted values,
        ink palette, per-field assignments, selected image assets, and paper
        substrate. It uses the same scene/layout renderer as final output;
        substrate is preview media and never becomes a press separation.

        Authenticate with X-StackFill-Client-Secret or the short-lived
        client_secret query parameter. Query authentication keeps the GET a
        CORS-simple request, but clients should prevent URLs from entering
        logs or analytics. A fill owned by another session returns 404.

        The response is image/png bytes. ETag and X-StackFill-Content-Hash are
        SHA-256 identities over fill state plus page/w/format/dpr. The mutable
        fill URL requires revalidation; StackFill also serves repeat states
        from a content-addressed R2 cache. Budget: 120 preview reads per
        embed-session secret per 60 seconds.
      x-stability: beta
      security:
        - embedSessionSecret: []
        - embedSessionSecretQuery: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, example: fill_123 }
        - name: client_secret
          in: query
          required: false
          description: Short-lived embed-session secret. Prefer the header outside CORS-simple hot paths.
          schema: { type: string, example: es_REPLACE_ME }
        - name: page
          in: query
          required: false
          description: 0-based scene page. Must be below the template page count.
          schema: { type: integer, minimum: 0, default: 0 }
        - name: w
          in: query
          required: false
          description: Target CSS width. The physical width is min(w × dpr, 2560).
          schema: { type: integer, minimum: 1, maximum: 2560, default: 880 }
        - name: format
          in: query
          required: false
          schema: { type: string, enum: [png], default: png }
        - name: dpr
          in: query
          required: false
          description: Device-pixel ratio used to derive physical raster dimensions.
          schema: { type: number, minimum: 0.5, maximum: 4, default: 1 }
      responses:
        "200":
          description: PNG raster of the current fill state.
          headers:
            ETag: { $ref: "#/components/headers/ETag" }
            X-StackFill-Content-Hash: { $ref: "#/components/headers/XContentHash" }
            X-StackFill-Preview-Width: { $ref: "#/components/headers/XPreviewWidth" }
            X-StackFill-Preview-Height: { $ref: "#/components/headers/XPreviewHeight" }
            X-StackFill-Cache: { $ref: "#/components/headers/XPreviewCache" }
            X-RateLimit-Limit: { $ref: "#/components/headers/XRateLimitLimit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/XRateLimitRemaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/XRateLimitReset" }
          content:
            image/png:
              schema: { type: string, format: binary }
        "304":
          description: The caller's If-None-Match already identifies the current fill state and render parameters.
          headers:
            ETag: { $ref: "#/components/headers/ETag" }
            X-StackFill-Content-Hash: { $ref: "#/components/headers/XContentHash" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "410":
          description: The embed session expired or is closed.
        "422":
          description: page, w, dpr, or format is invalid.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/ServerError" }
        "502":
          description: Raster renderer was unreachable or returned an error.
        "503":
          description: Raster renderer is not configured.
  /embed/fills/{id}/complete:
    post:
      operationId: completeEmbedFill
      tags: [embed_fills]
      summary: Mark an embed fill ready to attach to the cart
      description: |
        Flips the embed session to completed and returns the fill for the cart
        attach. Finalize (the press render) stays server-side via
        POST /fills/{id}/finalize with the secret key.
      x-stability: beta
      security:
        - embedSessionSecret: []
        - embedSessionSecretQuery: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: Session completed; fill returned.
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "410":
          description: The embed session expired or is closed.
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/ServerError" }
  /embed/sessions/{id}:
    get:
      operationId: getEmbedSession
      tags: [embed_sessions]
      summary: Retrieve an embed session
      x-stability: stable
      parameters:
        - $ref: "#/components/parameters/id"
      responses:
        "200":
          description: Embed session.
          content:
            application/json:
              schema:
                type: object
                required: [embed_session]
                properties:
                  embed_session: { $ref: "#/components/schemas/EmbedSession" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/ServerError" }
    patch:
      operationId: updateEmbedSession
      tags: [embed_sessions]
      summary: Cancel or expire an embed session
      x-stability: stable
      parameters:
        - $ref: "#/components/parameters/id"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                status:
                  type: string
                  enum: [canceled, expired]
                  example: canceled
      responses:
        "200":
          description: Embed session updated.
          content:
            application/json:
              schema:
                type: object
                required: [embed_session]
                properties:
                  embed_session: { $ref: "#/components/schemas/EmbedSession" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/ServerError" }
    delete:
      operationId: deleteEmbedSession
      tags: [embed_sessions]
      summary: Cancel an embed session
      x-stability: stable
      parameters:
        - $ref: "#/components/parameters/id"
      responses:
        "200":
          description: Embed session canceled.
          content:
            application/json:
              schema:
                type: object
                required: [embed_session]
                properties:
                  embed_session: { $ref: "#/components/schemas/EmbedSession" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/ServerError" }
  /webhook_endpoints:
    get:
      operationId: listWebhookEndpoints
      tags: [webhooks]
      summary: List webhook endpoints
      x-stability: beta
      responses:
        "200":
          description: Webhook endpoint list.
          content:
            application/json:
              schema:
                type: object
                required: [data, has_more]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/WebhookEndpoint" }
                  has_more: { type: boolean, example: false }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/ServerError" }
    post:
      operationId: createWebhookEndpoint
      tags: [webhooks]
      summary: Create a webhook endpoint
      x-stability: beta
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookEndpointCreate" }
      responses:
        "201":
          description: Webhook endpoint created.
          content:
            application/json:
              schema:
                type: object
                required: [webhook_endpoint]
                properties:
                  webhook_endpoint: { $ref: "#/components/schemas/WebhookEndpoint" }
                  signing_secret:
                    type: string
                    description: Revealed once.
                    example: whsec_REPLACE_ME
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/ServerError" }
  /webhook_endpoints/{id}:
    get:
      operationId: getWebhookEndpoint
      tags: [webhooks]
      summary: Retrieve a webhook endpoint
      x-stability: beta
      parameters:
        - $ref: "#/components/parameters/id"
      responses:
        "200":
          description: Webhook endpoint.
          content:
            application/json:
              schema:
                type: object
                required: [webhook_endpoint]
                properties:
                  webhook_endpoint: { $ref: "#/components/schemas/WebhookEndpoint" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/ServerError" }
    patch:
      operationId: updateWebhookEndpoint
      tags: [webhooks]
      summary: Update or rotate a webhook endpoint
      x-stability: beta
      parameters:
        - $ref: "#/components/parameters/id"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookEndpointUpdate" }
      responses:
        "200":
          description: Webhook endpoint updated.
          content:
            application/json:
              schema:
                type: object
                required: [webhook_endpoint]
                properties:
                  webhook_endpoint: { $ref: "#/components/schemas/WebhookEndpoint" }
                  signing_secret:
                    type: string
                    nullable: true
                    example: whsec_REPLACE_ME
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/ServerError" }
    delete:
      operationId: deleteWebhookEndpoint
      tags: [webhooks]
      summary: Disable a webhook endpoint
      x-stability: beta
      parameters:
        - $ref: "#/components/parameters/id"
      responses:
        "200":
          description: Webhook endpoint disabled.
          content:
            application/json:
              schema:
                type: object
                required: [webhook_endpoint]
                properties:
                  webhook_endpoint: { $ref: "#/components/schemas/WebhookEndpoint" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/ServerError" }
  /webhook_endpoints/{id}/deliveries:
    get:
      operationId: listWebhookDeliveries
      tags: [webhooks]
      summary: List webhook delivery attempts
      x-stability: beta
      parameters:
        - $ref: "#/components/parameters/id"
        - $ref: "#/components/parameters/limit"
      responses:
        "200":
          description: Delivery list.
          content:
            application/json:
              schema:
                type: object
                required: [data, has_more]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/WebhookDelivery" }
                  has_more: { type: boolean, example: false }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/ServerError" }
  /webhook_endpoints/{id}/deliveries/{delivery_id}:
    get:
      operationId: getWebhookDelivery
      tags: [webhooks]
      summary: Retrieve a single webhook delivery attempt
      description: |
        Returns the recorded delivery row, including the exact JSON
        payload that was sent (the body the signature was computed
        over). Useful for debugging signature verification on the
        consumer side without having to dig in their own logs.
      x-stability: beta
      parameters:
        - $ref: "#/components/parameters/id"
        - in: path
          name: delivery_id
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Delivery detail.
          content:
            application/json:
              schema:
                type: object
                required: [delivery]
                properties:
                  delivery: { $ref: "#/components/schemas/WebhookDelivery" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/ServerError" }
  /webhook_endpoints/{id}/deliveries/{delivery_id}/retry:
    post:
      operationId: retryWebhookDelivery
      tags: [webhooks]
      summary: Re-deliver a specific recorded webhook attempt
      description: |
        Inserts a new delivery row (attempt_number = previous + 1)
        targeting the same event_id, signed with a fresh timestamp.
        The original row is preserved. Returns 409 if the original
        delivery already succeeded (fire a fresh event instead),
        422 if it's been exhausted (max attempts hit) or was skipped.
      x-stability: beta
      parameters:
        - $ref: "#/components/parameters/id"
        - in: path
          name: delivery_id
          required: true
          schema: { type: string }
      responses:
        "200":
          description: A new delivery was attempted; returns the new row.
          content:
            application/json:
              schema:
                type: object
                required: [delivery]
                properties:
                  delivery: { $ref: "#/components/schemas/WebhookDelivery" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: Original delivery already succeeded or endpoint disabled.
        "422":
          description: Original delivery exhausted (max attempts) or skipped (no secret at time).
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/ServerError" }
  /webhook_endpoints/{id}/test:
    post:
      operationId: testWebhookEndpoint
      tags: [webhooks]
      summary: Send a stackfill.test event to an endpoint
      description: |
        Synthesizes a stackfill.test event and delivers it to the
        endpoint through the same sign+post+record path a real event
        uses. The recorded delivery shows up in /deliveries with
        attempt_number=1. Failures count toward consecutive_failures
        and the auto-disable threshold the same way real deliveries do.
      x-stability: beta
      parameters:
        - $ref: "#/components/parameters/id"
      responses:
        "200":
          description: Test event dispatched.
          content:
            application/json:
              schema:
                type: object
                required: [delivery, sent_status]
                properties:
                  delivery: { $ref: "#/components/schemas/WebhookDelivery" }
                  sent_status:
                    type: string
                    enum: [succeeded, failed, skipped, exhausted]
                  sent_status_code: { type: integer, nullable: true }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: Endpoint is disabled.
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/ServerError" }
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: StackFill API key
      description: "Secret or restricted API key sent as Authorization: Bearer."
    publishableKey:
      type: apiKey
      in: header
      name: X-StackFill-Publishable-Key
      description: Browser-safe publishable key scoped by allowed origins.
    embedSessionSecret:
      type: apiKey
      in: header
      name: X-StackFill-Client-Secret
      description: Short-lived client secret for iframe session APIs.
    embedSessionSecretQuery:
      type: apiKey
      in: query
      name: client_secret
      description: Short-lived client secret for CORS-simple embed preview GETs.
  parameters:
    id:
      name: id
      in: path
      required: true
      schema: { type: string, example: tpl_123 }
    limit:
      name: limit
      in: query
      required: false
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 50
  headers:
    XRequestId:
      description: Request identifier for support and logs.
      schema: { type: string, example: req_123 }
    XRateLimitLimit:
      description: Request limit for the current window.
      schema: { type: integer, example: 60 }
    XRateLimitRemaining:
      description: Requests remaining in the current window.
      schema: { type: integer, example: 58 }
    XRateLimitReset:
      description: Unix timestamp when the current rate-limit window resets.
      schema: { type: integer, example: 1760000000 }
    RetryAfter:
      description: Seconds to wait before retrying.
      schema: { type: integer, example: 30 }
    IdempotentReplay:
      description: Present when an idempotent response was replayed.
      schema: { type: boolean, example: true }
    Deprecation:
      description: Present when the operation or key is deprecated.
      schema: { type: boolean, example: true }
    Sunset:
      description: RFC 8594 sunset timestamp for deprecated behavior.
      schema: { type: string, format: date-time }
    ETag:
      description: Strong SHA-256 identity for fill state plus render parameters.
      schema: { type: string, example: '"8a5f..."' }
    XContentHash:
      description: Stable SHA-256 identity for fill state plus render parameters.
      schema: { type: string, example: sha256:8a5f... }
    XPreviewWidth:
      description: Physical PNG width in pixels after DPR and clamping.
      schema: { type: integer, example: 1760 }
    XPreviewHeight:
      description: Physical PNG height in pixels.
      schema: { type: integer, example: 1006 }
    XPreviewCache:
      description: Whether the PNG came from StackFill's content-addressed cache.
      schema: { type: string, enum: [HIT, MISS, REVALIDATED, BYPASS] }
  schemas:
    ListEnvelope:
      type: object
      required: [data, has_more]
      properties:
        data:
          type: array
          items:
            oneOf:
              - $ref: "#/components/schemas/Template"
              - $ref: "#/components/schemas/ProductMapping"
              - $ref: "#/components/schemas/EmbedApplication"
              - $ref: "#/components/schemas/EmbedSession"
              - $ref: "#/components/schemas/Render"
              - $ref: "#/components/schemas/WebhookEndpoint"
              - $ref: "#/components/schemas/WebhookDelivery"
        has_more: { type: boolean, example: false }
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [type, code, message, doc_url]
          properties:
            type:
              type: string
              enum: [invalid_request_error, authentication_error, plan_limit_error, rate_limit_error, idempotency_error, not_found_error, api_error, service_unavailable]
              example: invalid_request_error
            code: { type: string, example: missing_fields }
            message: { type: string, example: template_id is required. }
            doc_url: { type: string, format: uri, example: https://stackfill.com/docs/errors/missing_fields }
            request_id: { type: string, nullable: true, example: req_123 }
    ApiKey:
      type: object
      required: [id, type, mode, last4, status, created_at]
      properties:
        id: { type: string, example: key_123 }
        type: { type: string, enum: [secret, restricted, publishable], example: publishable }
        mode: { type: string, enum: [live, test], example: live }
        name: { type: string, example: Storefront publishable key }
        last4: { type: string, example: Ab12 }
        scopes:
          type: array
          items: { type: string }
          example: [embed:sessions:create, product_mappings:resolve]
        allowed_origins:
          type: array
          items: { type: string }
          example: [https://shop.example.com]
        last_used_at: { type: string, format: date-time, nullable: true }
        last_used_ip: { type: string, nullable: true }
        status: { type: string, enum: [active, paused, revoked], example: active }
        created_at: { type: string, format: date-time }
    Template:
      type: object
      required: [id, title, slug, status, page_count, created_at, updated_at]
      properties:
        id: { type: string, example: tpl_123 }
        title: { type: string, example: Baptist business card }
        slug: { type: string, example: baptist-business-card }
        status: { type: string, enum: [draft, live, archived], example: live }
        page_count: { type: integer, example: 1 }
        latest_revision: { type: integer, nullable: true, example: 4 }
        default_font_id: { type: string, nullable: true, example: inter-regular }
        trim_w_pt: { type: number, nullable: true, example: 252 }
        trim_h_pt: { type: number, nullable: true, example: 144 }
        has_bleed: { type: boolean, example: true }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    SceneSummary:
      type: object
      required: [pages]
      properties:
        title: { type: string, nullable: true, example: Baptist business card }
        pages:
          type: array
          items:
            type: object
            properties:
              id: { type: string, example: page_1 }
              pageSize:
                type: object
                properties:
                  width: { type: number, example: 252 }
                  height: { type: number, example: 144 }
        fonts:
          type: object
          properties:
            default: { type: string, nullable: true, example: inter-regular }
        _meta:
          type: object
          properties:
            parserVersion: { type: string, example: 2026-05-23 }
    Field:
      type: object
      required: [id, label, type, default_value, max_length]
      properties:
        id:
          type: string
          description: Canonical key accepted in values and assignments maps.
          example: fld_name
        label: { type: string, example: Name }
        type: { type: string, enum: [text, selection, image], example: text }
        default_value: { type: string, example: Firstname Lastname }
        max_length: { type: integer, example: 200 }
        selection_set_id: { type: string, nullable: true, example: gsel_us_states }
        options:
          type: array
          nullable: true
          items: { type: string }
          example: [Primary Care, Cardiology]
        images:
          type: array
          nullable: true
          items:
            type: object
            required: [id, name]
            properties:
              id: { type: string, example: img_logo_1 }
              name: { type: string, example: Approved logo }
              file_format: { type: string, example: png }
              url: { type: string, format: uri, nullable: true }
    ProductMapping:
      type: object
      required: [id, template_id, external_product_id, status, created_at, updated_at]
      properties:
        id: { type: string, example: pm_123 }
        template_id: { type: string, example: tpl_123 }
        template_title: { type: string, nullable: true, example: Baptist business card }
        template_slug: { type: string, nullable: true, example: baptist-business-card }
        embed_application_id: { type: string, nullable: true, example: embapp_123 }
        scene_revision: { type: integer, nullable: true, example: 1 }
        external_product_id: { type: string, example: business-card }
        external_variant_id: { type: string, nullable: true, example: standard }
        external_sku: { type: string, nullable: true, example: BC-STANDARD }
        title: { type: string, nullable: true, example: Business card }
        status: { type: string, enum: [active, paused, archived], example: active }
        rules: { type: object, additionalProperties: true }
        metadata: { type: object, additionalProperties: true }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    ProductMappingCreate:
      type: object
      required: [template_id, external_product_id]
      properties:
        template_id: { type: string, example: tpl_123 }
        embed_application_id: { type: string, nullable: true, example: embapp_123 }
        scene_revision: { type: integer, nullable: true, example: 1 }
        external_product_id: { type: string, example: business-card }
        external_variant_id: { type: string, nullable: true, example: standard }
        external_sku: { type: string, nullable: true, example: BC-STANDARD }
        title: { type: string, nullable: true, example: Business card }
        status: { type: string, enum: [active, paused, archived], default: active }
        rules: { type: object, additionalProperties: true }
        metadata: { type: object, additionalProperties: true }
    ProductMappingUpdate:
      type: object
      properties:
        embed_application_id: { type: string, nullable: true }
        scene_revision: { type: integer, nullable: true }
        external_product_id: { type: string }
        external_variant_id: { type: string, nullable: true }
        external_sku: { type: string, nullable: true }
        title: { type: string, nullable: true }
        status: { type: string, enum: [active, paused, archived] }
        rules: { type: object, additionalProperties: true }
        metadata: { type: object, additionalProperties: true }
    ProductMappingResolveRequest:
      type: object
      required: [external_product_id]
      properties:
        publishable_key:
          type: string
          description: Accepted for copy/paste; prefer X-StackFill-Publishable-Key.
          example: stackfill_live_pk_REPLACE_ME
        external_product_id: { type: string, example: business-card }
        external_variant_id: { type: string, nullable: true, example: standard }
    ProductMappingResolveResponse:
      type: object
      required: [product_mapping, template]
      properties:
        product_mapping: { $ref: "#/components/schemas/ProductMapping" }
        embed_application:
          type: object
          nullable: true
          properties:
            id: { type: string, example: embapp_123 }
            name: { type: string, example: Shop storefront }
            mode: { type: string, enum: [live, test], example: live }
        template:
          type: object
          required: [id, title, slug, scene_revision]
          properties:
            id: { type: string, example: tpl_123 }
            title: { type: string, example: Baptist business card }
            slug: { type: string, example: baptist-business-card }
            scene_revision: { type: integer, nullable: true, example: 1 }
    EmbedApplication:
      type: object
      required: [id, name, mode, allowed_origins, status, created_at, updated_at]
      properties:
        id: { type: string, example: embapp_123 }
        name: { type: string, example: Shop storefront }
        mode: { type: string, enum: [live, test], example: live }
        publishable_key_id: { type: string, nullable: true, example: key_123 }
        allowed_origins:
          type: array
          items: { type: string }
          example: [https://shop.example.com]
        branding: { type: object, additionalProperties: true }
        settings: { type: object, additionalProperties: true }
        status: { type: string, enum: [active, paused, archived], example: active }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    EmbedApplicationCreate:
      type: object
      required: [name, allowed_origins]
      properties:
        name: { type: string, example: Shop storefront }
        mode: { type: string, enum: [live, test], default: live }
        publishable_key_id: { type: string, nullable: true }
        allowed_origins:
          type: array
          items: { type: string }
          example: [https://shop.example.com]
        create_publishable_key: { type: boolean, default: true }
        branding: { type: object, additionalProperties: true }
        settings: { type: object, additionalProperties: true }
        status: { type: string, enum: [active, paused, archived], default: active }
    EmbedApplicationUpdate:
      type: object
      properties:
        name: { type: string }
        publishable_key_id: { type: string, nullable: true }
        allowed_origins:
          type: array
          items: { type: string }
        branding: { type: object, additionalProperties: true }
        settings: { type: object, additionalProperties: true }
        status: { type: string, enum: [active, paused, archived] }
    CustomerContact:
      type: object
      properties:
        email: { type: string, format: email, nullable: true, example: customer@example.com }
        name: { type: string, nullable: true, example: Jordan Tate }
    EmbedSession:
      type: object
      required: [id, template_id, scene_revision, mode, status, expires_at, created_at]
      properties:
        id: { type: string, example: es_123 }
        template_id: { type: string, example: tpl_123 }
        scene_revision: { type: integer, example: 1 }
        embed_application_id: { type: string, nullable: true, example: embapp_123 }
        product_mapping_id: { type: string, nullable: true, example: pm_123 }
        fill_id: { type: string, nullable: true, example: fill_123 }
        client_secret:
          type: string
          nullable: true
          description: Returned only when creating a session.
          example: esf_client_secret_REPLACE_ME
        mode: { type: string, enum: [live, test], example: live }
        status: { type: string, enum: [open, saved, canceled, expired], example: open }
        origin: { type: string, nullable: true, example: https://shop.example.com }
        external_product_id: { type: string, nullable: true, example: business-card }
        external_variant_id: { type: string, nullable: true, example: standard }
        external_cart_id: { type: string, nullable: true, example: cart_123 }
        external_order_id: { type: string, nullable: true, example: order_123 }
        external_customer_id: { type: string, nullable: true, example: cust_123 }
        customer_email: { type: string, format: email, nullable: true, example: customer@example.com }
        customer_name: { type: string, nullable: true, example: Jordan Tate }
        metadata: { type: object, additionalProperties: true }
        expires_at: { type: string, format: date-time }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time, nullable: true }
    EmbedSessionCreate:
      type: object
      properties:
        template_id:
          type: string
          description: Explicit template selection. When provided, this takes precedence over the template resolved from a product mapping.
          example: tpl_123
        product_mapping_id: { type: string, example: pm_123 }
        embed_application_id: { type: string, example: embapp_123 }
        external_product_id: { type: string, example: business-card }
        external_variant_id: { type: string, example: standard }
        external_cart_id: { type: string, example: cart_123 }
        external_order_id: { type: string, example: order_123 }
        external_customer_id: { type: string, example: cust_123 }
        customer: { $ref: "#/components/schemas/CustomerContact" }
        metadata: { type: object, additionalProperties: true }
        ttl_seconds: { type: integer, minimum: 300, maximum: 86400, default: 7200 }
    EmbedSessionGuestCreate:
      allOf:
        - $ref: "#/components/schemas/EmbedSessionCreate"
        - type: object
          required: [external_product_id]
          properties:
            publishable_key:
              type: string
              description: Accepted for copy/paste; prefer X-StackFill-Publishable-Key.
              example: stackfill_live_pk_REPLACE_ME
    EmbedSessionCreateResponse:
      type: object
      required: [embed_session, iframe_url]
      properties:
        embed_session: { $ref: "#/components/schemas/EmbedSession" }
        iframe_url:
          type: string
          format: uri
          example: https://embed.stackfill.com/v1/sessions/es_123#client_secret=REPLACE_ME
    Fill:
      type: object
      required: [id, template_id, scene_revision, status, source, created_at]
      properties:
        id: { type: string, example: fill_123 }
        template_id: { type: string, example: tpl_123 }
        scene_revision: { type: integer, example: 1 }
        status: { type: string, enum: [open, finalized, expired], example: open }
        source: { type: string, enum: [hosted, embed, api], example: embed }
        values: { type: object, additionalProperties: true }
        external_product_id: { type: string, nullable: true, example: business-card }
        external_variant_id: { type: string, nullable: true, example: standard }
        external_cart_id: { type: string, nullable: true, example: cart_123 }
        external_order_id: { type: string, nullable: true, example: order_123 }
        customer_email: { type: string, format: email, nullable: true }
        customer_name: { type: string, nullable: true }
        created_at: { type: string, format: date-time }
        finalized_at: { type: string, format: date-time, nullable: true }
    FillCreate:
      type: object
      required: [template_id, values]
      properties:
        template_id: { type: string, example: tpl_123 }
        scene_revision: { type: integer, example: 1 }
        values: { type: object, additionalProperties: true }
        customer: { $ref: "#/components/schemas/CustomerContact" }
        metadata: { type: object, additionalProperties: true }
    FillUpdate:
      type: object
      properties:
        values: { type: object, additionalProperties: true }
        customer: { $ref: "#/components/schemas/CustomerContact" }
        metadata: { type: object, additionalProperties: true }
    Render:
      type: object
      required: [id, render_id, generation_id, kind, status]
      properties:
        id: { type: string, example: rnd_123 }
        render_id: { type: string, example: rnd_123 }
        generation_id:
          type: string
          description: Alias for render_id used by dashboard generation history.
          example: rnd_123
        template_id: { type: string, nullable: true, example: tpl_123 }
        template_title: { type: string, nullable: true, example: FIU Baptist Business Card }
        template_slug: { type: string, nullable: true, example: fiu-baptist-card }
        project_id: { type: string, nullable: true, example: proj_healthcare }
        project_name: { type: string, nullable: true, example: Healthcare demo project }
        fill_id: { type: string, nullable: true, example: fill_123 }
        customer_email: { type: string, format: email, nullable: true, example: jordan@example.com }
        customer_name: { type: string, nullable: true, example: Jordan Tate }
        fill_status: { type: string, nullable: true, example: finalized }
        source: { type: string, nullable: true, enum: [hosted, embed, api, demo], example: embed }
        external_product_id: { type: string, nullable: true, example: demo-baptist-card }
        external_variant_id: { type: string, nullable: true, example: standard }
        external_cart_id: { type: string, nullable: true, example: cart_demo_001 }
        external_order_id: { type: string, nullable: true, example: order_1001 }
        scene_revision: { type: integer, nullable: true, example: 4 }
        batch_id: { type: string, nullable: true, example: batch_123 }
        kind: { type: string, enum: [preview, final], example: final }
        status: { type: string, enum: [queued, succeeded, failed], example: succeeded }
        signed_url:
          type: string
          format: uri
          nullable: true
          description: Short-lived URL when the PDF is available.
        preflight_status: { type: string, nullable: true, enum: [pending, pass, warn, fail, failed], example: pass }
        preflight_notes:
          type: string
          nullable: true
          description: Renderer or preflight details. May be JSON text.
          example: "{\"message\":\"CMYK profile retained\"}"
        separation:
          type: string
          nullable: true
          enum: [spot]
          description: Press-pass encoding used by the final PDF. Null when no fill-level ink palette was supplied.
          example: spot
        inks:
          type: array
          description: Immutable press ink manifest captured when the PDF was rendered.
          items:
            type: object
            required: [index, key, name]
            properties:
              index: { type: integer, minimum: 1, example: 1 }
              key: { type: string, example: merlot }
              name: { type: string, example: Merlot }
        size_bytes: { type: integer, nullable: true, example: 842116 }
        created_at: { type: string, format: date-time, nullable: true }
    RenderCreate:
      type: object
      required: [template_id, scene_revision]
      properties:
        template_id: { type: string, example: tpl_123 }
        scene_revision: { type: integer, example: 1 }
        kind: { type: string, enum: [final], default: final }
        values: { type: object, additionalProperties: true }
        colors:
          type: array
          description: Optional fill-level press inks. Each name becomes a named PDF spot separation.
          items:
            type: object
            required: [index, name]
            properties:
              index: { type: integer, minimum: 1, example: 1 }
              key: { type: string, example: merlot }
              name: { type: string, example: Merlot }
              hex:
                type: string
                pattern: '^#?[0-9a-fA-F]{6}$'
                description: Screen/alternate approximation only; not a press formula.
                example: '#6e1f2e'
        assignments:
          type: object
          description: Placeholder key to 1-based color index. Omitted placeholders use ink 1.
          additionalProperties:
            type: integer
            minimum: 1
    WebhookEndpoint:
      type: object
      required: [id, url, events, status, mode, created_at, updated_at]
      properties:
        id: { type: string, example: we_123 }
        url: { type: string, format: uri, example: https://example.com/webhooks/stackfill }
        events:
          type: array
          items:
            type: string
            enum: [embed_session.saved, fill.created, render.succeeded, batch.completed]
          example: [embed_session.saved, render.succeeded]
        status: { type: string, enum: [enabled, disabled], example: enabled }
        mode: { type: string, enum: [live, test], example: live }
        signing_secret_last4: { type: string, nullable: true, example: Ab12 }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    WebhookEndpointCreate:
      type: object
      required: [url]
      properties:
        url: { type: string, format: uri, example: https://example.com/webhooks/stackfill }
        events:
          type: array
          items: { type: string }
          example: [embed_session.saved, render.succeeded]
        status: { type: string, enum: [enabled, disabled], default: enabled }
        mode: { type: string, enum: [live, test], default: live }
    WebhookEndpointUpdate:
      type: object
      properties:
        url: { type: string, format: uri }
        events:
          type: array
          items: { type: string }
        status: { type: string, enum: [enabled, disabled] }
        rotate_secret: { type: boolean, default: false }
    WebhookDelivery:
      type: object
      required: [id, event_id, endpoint_id, event_type, attempt_number, status, created_at]
      properties:
        id: { type: string, example: wd_123 }
        event_id: { type: string, example: evt_123 }
        endpoint_id: { type: string, example: we_123 }
        event_type: { type: string, example: embed_session.saved }
        attempt_number: { type: integer, example: 1 }
        status: { type: string, enum: [pending, succeeded, failed, skipped], example: succeeded }
        status_code: { type: integer, nullable: true, example: 200 }
        response_body_truncated: { type: string, nullable: true }
        duration_ms: { type: integer, nullable: true, example: 124 }
        next_retry_at: { type: string, format: date-time, nullable: true }
        delivered_at: { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time }
    Event:
      type: object
      required: [id, type, created_at, data]
      properties:
        id: { type: string, example: evt_123 }
        type: { type: string, example: embed_session.saved }
        mode: { type: string, enum: [live, test], example: live }
        created_at: { type: string, format: date-time }
        data: { type: object, additionalProperties: true }
    MoneyAmount:
      type: object
      required: [amount_cents, currency]
      properties:
        amount_cents: { type: integer, example: 2900 }
        currency: { type: string, minLength: 3, maxLength: 3, example: usd }
  responses:
    BadRequest:
      description: Bad request.
      headers:
        X-Request-Id: { $ref: "#/components/headers/XRequestId" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Unauthorized:
      description: Authentication failed.
      headers:
        X-Request-Id: { $ref: "#/components/headers/XRequestId" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Forbidden:
      description: Key scope, origin, or permission denied.
      headers:
        X-Request-Id: { $ref: "#/components/headers/XRequestId" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    PaymentRequired:
      description: Plan, entitlement, subscription, or quota block.
      headers:
        X-Request-Id: { $ref: "#/components/headers/XRequestId" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    NotFound:
      description: Resource not found.
      headers:
        X-Request-Id: { $ref: "#/components/headers/XRequestId" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Conflict:
      description: Conflicting resource state.
      headers:
        X-Request-Id: { $ref: "#/components/headers/XRequestId" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    RateLimited:
      description: Rate limit exceeded.
      headers:
        X-Request-Id: { $ref: "#/components/headers/XRequestId" }
        Retry-After: { $ref: "#/components/headers/RetryAfter" }
        X-RateLimit-Limit: { $ref: "#/components/headers/XRateLimitLimit" }
        X-RateLimit-Remaining: { $ref: "#/components/headers/XRateLimitRemaining" }
        X-RateLimit-Reset: { $ref: "#/components/headers/XRateLimitReset" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    IdempotencyMismatch:
      description: Idempotency key reused with a different request body.
      headers:
        X-Request-Id: { $ref: "#/components/headers/XRequestId" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    ServerError:
      description: Unexpected API error.
      headers:
        X-Request-Id: { $ref: "#/components/headers/XRequestId" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
