openapi: 3.1.0
info:
  title: StackFill API
  version: "2026-05-23"
  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: 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. Preview renders are not exposed through /v1.
      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:
                    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 label → value, applied before rastering.
                page: { type: integer, minimum: 0, default: 0, description: 0-indexed page (front=0). }
                dpi: { type: integer, minimum: 72, maximum: 300, default: 192 }
      responses:
        "200":
          description: PNG raster.
          content:
            image/png:
              schema: { type: string, format: binary }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "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/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.
  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 }
  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: [name, label, type, required]
      properties:
        name: { type: string, example: Name }
        label: { type: string, example: Name }
        type: { type: string, enum: [text, selection, image], example: text }
        required: { type: boolean, example: true }
        options:
          type: array
          nullable: true
          items: { type: string }
          example: [Primary Care, Cardiology]
    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, 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\"}"
        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 }
    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" }
