openapi: 3.1.0
info:
  title: giantpanda.site platform API (/_gp/v1)
  version: 0.1.0-draft
  summary: Same-origin API for configurators and sites hosted on gianthost.
  description: |
    Served on every gianthost host that has the feature enabled, under `/_gp/v1`.
    Visitor identification, cookies, consent storage, network data and backend
    registration are handled by the platform; clients never send IP, user agent
    or visitor ids.

    Conventions
    - All field names camelCase. Enum values UPPER_SNAKE_CASE.
    - Writes require `X-GP-Token` (from boot) and same-origin requests.
    - Errors are `application/problem+json` (RFC 9457) with a stable `code`.
    - Image URLs in responses are opaque (`/_gp/img/{token}`); add `?w=` for a
      width. Tokens expire; never build, parse or store them.

    Status: DRAFT. Backing service: widget-service (mapped by gp-api).
    Configurators are addressed by slug (per site); ids are never exposed.
    Scope: standalone only. Embedding (iframe/embed.js) is out of scope for now.
servers:
  - url: /_gp/v1
    description: Same origin as the configurator/site

tags:
  - name: boot
  - name: definition
  - name: lead
  - name: event
  - name: consent

security:
  - gpToken: []

paths:
  /boot:
    post:
      tags: [boot]
      operationId: siteBoot
      summary: Start a page session for the site (every page)
      description: |
        Call once per page load on EVERY page, with or without a configurator. Registers the page
        visit for the site (visitor counts in Hub) and returns the write token, consent state and
        opaque tracking values, like the configurator boot but without configurator ids
        (`settings.gtmPixelId` is null). Never fails because of the visit registration. A page with
        a configurator also calls the configurator boot.
      security: []
      responses:
        '200':
          description: Session started
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BootResponse' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /configurators/{slug}/boot:
    post:
      tags: [boot]
      operationId: boot
      summary: Start a page session for a configurator
      description: |
        Call once per page load. Registers the visit, returns the write token,
        consent state, opaque tracking values for GTM and the pixel settings.
        Call again when a write returns `401 token_expired`.
      security: []
      parameters:
        - $ref: '#/components/parameters/Slug'
      responses:
        '200':
          description: Session started
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BootResponse' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /definitions/{slug}:
    get:
      tags: [definition]
      operationId: getDefinition
      summary: Public configurator definition
      description: |
        Cached (up to ~60 s stale); served from a snapshot when the backend is
        unavailable. Separate path from `/configurators/*` so the edge can use a
        GET-only failover behavior.
      security: []
      parameters:
        - $ref: '#/components/parameters/Slug'
        - name: language
          in: query
          required: false
          schema: { type: string, examples: [nl] }
      responses:
        '200':
          description: Definition
          headers:
            ETag: { schema: { type: string } }
            Cache-Control: { schema: { type: string } }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Configurator' }
        '304': { description: Not modified }
        '404': { $ref: '#/components/responses/NotFound' }

  /leads/{leadId}:
    parameters:
      - $ref: '#/components/parameters/LeadId'
    get:
      tags: [lead]
      operationId: getLead
      summary: Resume a lead of the current session
      responses:
        '200':
          description: Lead
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Lead' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
    put:
      tags: [lead]
      operationId: saveLead
      summary: Save (and optionally submit) a lead — idempotent
      description: |
        `leadId` is minted by the client (UUID) once per configuration and
        reused for every save and the submit. Repeating a request is safe.
        `202` means accepted and delivered later (backend unavailable): treat
        it as success.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/LeadInput' }
      responses:
        '200':
          description: Saved
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Lead' }
        '202':
          description: Accepted, queued for delivery
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Queued' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409':
          description: Lead already submitted; start a new configuration with a new id
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /leads/{leadId}/attachments:
    parameters:
      - $ref: '#/components/parameters/LeadId'
    post:
      tags: [lead]
      operationId: uploadAttachment
      summary: Upload a file for a file question
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  contentMediaType: application/octet-stream
            encoding:
              file:
                contentType: image/*, application/pdf
      responses:
        '200':
          description: Stored
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Attachment' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '413':
          description: File too large
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }

  /events:
    post:
      tags: [event]
      operationId: sendEvent
      summary: Report a visitor action (fire-and-forget)
      description: |
        `CONFIGURING`: only from a real user action with an answer that has
        content; once per configurator per page load; again after a submit.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/EventInput' }
      responses:
        '202': { description: Accepted }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /consent:
    get:
      tags: [consent]
      operationId: getConsent
      security: []
      responses:
        '200':
          description: Current consent state
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Consent' }
    put:
      tags: [consent]
      operationId: setConsent
      summary: Store the visitor's analytics choice
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ConsentInput' }
      responses:
        '204': { description: Stored }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }

components:
  securitySchemes:
    gpToken:
      type: apiKey
      in: header
      name: X-GP-Token
      description: Token from boot. Session-bound, ~1 h.

  parameters:
    Slug:
      name: slug
      in: path
      required: true
      description: Configurator slug, only valid on this site. Ids are never exposed.
      schema: { type: string, pattern: '^[a-z0-9][a-z0-9-]{0,63}$' }
    LeadId:
      name: leadId
      in: path
      required: true
      schema: { type: string, format: uuid }

  responses:
    BadRequest:
      description: Invalid request
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    Unauthorized:
      description: Missing or expired token (`token_missing`, `token_expired`); boot again
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    Forbidden:
      description: Origin check failed, unknown host or feature disabled
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    NotFound:
      description: Unknown configurator or lead
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    TooManyRequests:
      description: Rate limited
      headers:
        Retry-After: { schema: { type: integer } }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }

  schemas:
    # ---------- shared ----------
    Problem:
      type: object
      required: [type, title, status, code]
      properties:
        type: { type: string, format: uri-reference, examples: [/_gp/errors/token-expired] }
        title: { type: string }
        status: { type: integer }
        code:
          type: string
          enum: [invalid_request, token_missing, token_expired, forbidden_origin,
                 unknown_host, feature_disabled, not_found, already_submitted,
                 file_too_large, rate_limited, upstream_unavailable, internal]
        detail: { type: string }
        errors:
          type: array
          items:
            type: object
            properties:
              path: { type: string }
              message: { type: string }

    ImageUrl:
      type: string
      description: Opaque image URL (`/_gp/img/{token}`); append `?w=480|800|1200|1920`.
      examples: [/_gp/img/v1.20261030.Zm9vYmFy]

    # ---------- boot ----------
    BootResponse:
      type: object
      required: [token, tracking, consent, settings]
      properties:
        token: { type: string }
        tokenExpiresAt: { type: string, format: date-time }
        tracking:
          type: object
          description: Opaque values. Spread `gtm` into every dataLayer push and `conversion` into `configurator.conversion`.
          required: [gtm, conversion]
          properties:
            gtm: { type: object, additionalProperties: true }
            conversion: { type: object, additionalProperties: true }
        consent: { $ref: '#/components/schemas/Consent' }
        settings:
          type: object
          properties:
            gtmPixelId: { type: [string, 'null'], examples: [GTM-ABC123] }
            language: { type: string, examples: [nl] }

    # ---------- consent ----------
    Consent:
      type: object
      required: [required, analytics]
      properties:
        required:
          type: boolean
          description: True when the site needs the visitor's choice to be forwarded.
        analytics:
          type: string
          enum: [GRANTED, DENIED, UNKNOWN]

    ConsentInput:
      type: object
      required: [analytics]
      properties:
        analytics:
          type: string
          enum: [GRANTED, DENIED]

    # ---------- event ----------
    EventInput:
      type: object
      required: [event, configurator]
      properties:
        event:
          type: string
          enum: [CONFIGURING]
        configurator: { type: string, description: Slug }

    # ---------- definition ----------
    Configurator:
      type: object
      description: |
        WHITELIST: only what a configurator needs to render and submit. No logic,
        no styling, no internal settings. Hidden steps/questions are omitted.
        Steps, questions and answers are in display order.
      required: [slug, language, steps]
      additionalProperties: false
      properties:
        slug: { type: string }
        language: { type: string, examples: [nl] }
        steps:
          type: array
          items: { $ref: '#/components/schemas/Step' }

    Step:
      type: object
      required: [id, type, questions]
      additionalProperties: false
      properties:
        id: { type: string, format: uuid }
        type: { type: string, enum: [PAGE, FINAL_PAGE] }
        finalType:
          type: string
          enum: [THANK_YOU, REDIRECT]
          description: Only on FINAL_PAGE.
        redirectUrl:
          type: [string, 'null']
          description: Only on FINAL_PAGE.
        title: { type: [string, 'null'] }
        description: { type: [string, 'null'], description: Sanitised HTML }
        image:
          oneOf:
            - $ref: '#/components/schemas/ImageUrl'
            - type: 'null'
        questions:
          type: array
          items: { $ref: '#/components/schemas/Question' }

    Question:
      type: object
      required: [id, name, type, required, answers]
      additionalProperties: false
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        description: { type: [string, 'null'], description: Sanitised HTML }
        type:
          type: string
          enum: [RADIO, CHECK_BOX, NUMBER, TEXT, TEXT_AREA, DATE, TIME, FILE, COLOR, NESTED_SLIDER, AREA]
        technicalType:
          type: [string, 'null']
          description: Semantic field type (EMAIL, PHONE, FIRST_NAME, ...) for validation and GTM user_data.
        technicalName:
          type: [string, 'null']
          description: Stable name a hand-built configurator maps its own fields to.
        required: { type: boolean }
        placeholder: { type: [string, 'null'] }
        min: { type: [number, 'null'], description: NUMBER only }
        max: { type: [number, 'null'], description: NUMBER only }
        step: { type: [number, 'null'], description: NUMBER only }
        fileTypes:
          type: array
          items: { type: string }
          description: FILE only
        answers:
          type: array
          items: { $ref: '#/components/schemas/Answer' }

    Answer:
      type: object
      required: [id, text]
      additionalProperties: false
      properties:
        id: { type: string, format: uuid }
        text: { type: string }
        technicalName: { type: [string, 'null'] }
        description: { type: [string, 'null'] }
        image:
          oneOf:
            - $ref: '#/components/schemas/ImageUrl'
            - type: 'null'

    # ---------- lead ----------
    LeadField:
      type: object
      required: [questionId, label, type, value]
      properties:
        questionId: { type: string, description: Field identifier (question id) }
        label: { type: string }
        type: { type: string, description: Question type }
        technicalType: { type: [string, 'null'] }
        value: { description: Answer value (string, number, array or object per type) }
        count: { type: [number, 'null'] }
        price: { type: [number, 'null'] }
        priceCalculation: { type: [string, 'null'] }

    Attribution:
      type: object
      description: Collected client-side (URL, referrer, click ids). Mapped by gp-api to the lead's meta data.
      properties:
        landingUrl: { type: string }
        referrer: { type: [string, 'null'] }
        utmSource: { type: [string, 'null'] }
        utmMedium: { type: [string, 'null'] }
        utmCampaign: { type: [string, 'null'] }
        utmTerm: { type: [string, 'null'] }
        utmContent: { type: [string, 'null'] }
        clickIds:
          type: object
          description: gclid, gbraid, wbraid, fbclid, msclkid, ttclid, … as received
          additionalProperties: { type: string }
        channel: { type: [string, 'null'] }

    LeadInput:
      type: object
      required: [configurator, fields, submit]
      properties:
        configurator: { type: string, description: Slug }
        fields:
          type: array
          items: { $ref: '#/components/schemas/LeadField' }
        submit:
          type: boolean
          description: True on the final submit; false for intermediate saves.
        automation:
          type: boolean
          default: true
        timezone: { type: string, default: Europe/Amsterdam }
        context:
          type: object
          properties:
            language: { type: string }
            attribution: { $ref: '#/components/schemas/Attribution' }
            metaData:
              type: object
              maxProperties: 150
              description: >
                Flat extra lead meta_data from the configurator. paw_*, ip, country and
                user_agent are always set by the platform and are ignored here.
              additionalProperties:
                type: [string, boolean, 'null']
                maxLength: 2000

    Lead:
      type: object
      required: [id, configurator, status, fields]
      properties:
        id: { type: string, format: uuid }
        configurator: { type: string, description: Slug }
        status:
          type: string
          enum: [DRAFT, SUBMITTED]
        fields:
          type: array
          items: { $ref: '#/components/schemas/LeadField' }
        updatedAt: { type: string, format: date-time }

    Queued:
      type: object
      required: [id, queued]
      properties:
        id: { type: string, format: uuid }
        queued: { const: true }

    Attachment:
      type: object
      required: [url]
      properties:
        url: { type: string, description: Opaque URL; store it as the FILE answer value }
        name: { type: string }
        size: { type: integer }
        contentType: { type: string }
