openapi: 3.1.0
info:
  title: PriceInfo Merchant API
  version: 1.0.0-async-v2
  summary: Stable Merchant API v1 contract backed by asynchronous ingestion V2
  description: |
    Submit official store feeds and offer updates to PriceInfo. The HTTP contract
    remains under `/api/merchant/v1`; direct offer pushes and hosted imports use
    asynchronous ingestion V2 and return an import job for polling.

    Every request is scoped to the merchant that owns the bearer token. Reusing
    a stable `Idempotency-Key` for the same direct logical batch returns the
    original V2 import job instead of duplicating the batch.
  contact:
    name: PriceInfo merchant support
    email: info@priceinfo.eu
  license:
    name: PriceInfo API Terms
    url: https://priceinfo.eu/terms
servers:
  - url: https://priceinfo.eu/api/merchant/v1
    description: Production
security:
  - bearerAuth: []
tags:
  - name: Account
    description: Verify merchant access and enabled markets.
  - name: Feeds
    description: Configure and import hosted XML, CSV, or JSON feeds.
  - name: Direct offers
    description: Submit one offer or a bounded offer batch.
  - name: Imports
    description: Poll asynchronous jobs and inspect row-level errors.
paths:
  /account:
    get:
      tags: [Account]
      operationId: getMerchantAccount
      summary: Verify the key and read the merchant account
      responses:
        '200':
          description: Merchant identity, enabled markets, currencies, and counters.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AccountResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
  /feeds:
    get:
      tags: [Feeds]
      operationId: listMerchantFeeds
      summary: List hosted feed configurations
      parameters:
        - $ref: '#/components/parameters/Page'
        - $ref: '#/components/parameters/PerPage'
      responses:
        '200':
          description: Paginated merchant-owned feeds.
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Feed'
                  meta:
                    $ref: '#/components/schemas/PaginationMeta'
        '401':
          $ref: '#/components/responses/Unauthorized'
    post:
      tags: [Feeds]
      operationId: createMerchantFeed
      summary: Create a pending hosted feed configuration
      description: New feeds always start as `pending` and require a completed validation import before activation.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/FeedCreateRequest'
      responses:
        '201':
          description: Feed configuration created in pending state.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/FeedResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '422':
          $ref: '#/components/responses/ValidationError'
  /feeds/{feed}:
    parameters:
      - $ref: '#/components/parameters/FeedId'
    get:
      tags: [Feeds]
      operationId: getMerchantFeed
      summary: Read one hosted feed configuration
      responses:
        '200':
          description: Merchant-owned feed.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/FeedResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
    patch:
      tags: [Feeds]
      operationId: updateMerchantFeed
      summary: Update a hosted feed configuration
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/FeedUpdateRequest'
      responses:
        '200':
          description: Updated feed.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/FeedResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'
  /feeds/{feed}/import:
    post:
      tags: [Feeds, Imports]
      operationId: queueMerchantFeedImport
      summary: Queue a hosted feed import
      parameters:
        - $ref: '#/components/parameters/FeedId'
      responses:
        '202':
          description: Import accepted. Poll the returned job ID.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ImportJobResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: An import for this feed is already active. Poll the returned job.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/MessageResponse'
                  - type: object
                    required: [data]
                    properties:
                      data:
                        $ref: '#/components/schemas/ImportJob'
        '422':
          $ref: '#/components/responses/ValidationError'
        '429':
          $ref: '#/components/responses/QuotaExceeded'
  /offers:
    post:
      tags: [Direct offers, Imports]
      operationId: queueSingleMerchantOffer
      summary: Queue one offer update through asynchronous ingestion V2
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SingleOfferRequest'
      responses:
        '202':
          description: Batch accepted or idempotently replayed. Save data.id and poll it.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AcceptedImportJobResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: The requested country is unknown or inactive.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MessageResponse'
        '422':
          $ref: '#/components/responses/ValidationError'
        '429':
          $ref: '#/components/responses/QuotaExceeded'
  /offers/bulk:
    post:
      tags: [Direct offers, Imports]
      operationId: queueBulkMerchantOffers
      summary: Queue 1–100 offer updates through asynchronous ingestion V2
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/BulkOfferRequest'
      responses:
        '202':
          description: Batch accepted or idempotently replayed. Save data.id and poll it.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AcceptedImportJobResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: The requested country is unknown or inactive.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MessageResponse'
        '422':
          $ref: '#/components/responses/ValidationError'
        '429':
          $ref: '#/components/responses/QuotaExceeded'
  /imports:
    get:
      tags: [Imports]
      operationId: listMerchantImports
      summary: List import jobs
      parameters:
        - $ref: '#/components/parameters/Page'
        - $ref: '#/components/parameters/PerPage'
      responses:
        '200':
          description: Paginated merchant-owned import jobs.
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/ImportJob'
                  meta:
                    $ref: '#/components/schemas/PaginationMeta'
        '401':
          $ref: '#/components/responses/Unauthorized'
  /imports/{job}:
    get:
      tags: [Imports]
      operationId: getMerchantImport
      summary: Poll one import job
      parameters:
        - $ref: '#/components/parameters/JobId'
      responses:
        '200':
          description: Current job state and counters.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ImportJobResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
  /imports/{job}/errors:
    get:
      tags: [Imports]
      operationId: listMerchantImportErrors
      summary: List row-level errors for one import job
      parameters:
        - $ref: '#/components/parameters/JobId'
        - $ref: '#/components/parameters/Page'
        - $ref: '#/components/parameters/PerPage'
      responses:
        '200':
          description: Paginated row-level errors.
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/ImportError'
                  meta:
                    $ref: '#/components/schemas/PaginationMeta'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: Merchant API key
      description: Server-side key issued after merchant approval.
  parameters:
    FeedId:
      name: feed
      in: path
      required: true
      schema:
        type: integer
        minimum: 1
      description: Merchant-owned feed ID.
    JobId:
      name: job
      in: path
      required: true
      schema:
        type: integer
        minimum: 1
      description: Merchant-owned import job ID.
    Page:
      name: page
      in: query
      schema:
        type: integer
        minimum: 1
        default: 1
    PerPage:
      name: per_page
      in: query
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 25
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      description: Stable unique value for one logical direct batch. Reuse it only when retrying that same batch.
      schema:
        type: string
        minLength: 1
        maxLength: 255
      example: catalog-cz-2026-08-08-001
  responses:
    Unauthorized:
      description: Token missing, inactive, expired, or linked to an inactive merchant.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/MessageResponse'
          examples:
            missing:
              value:
                message: Merchant API token is required.
            invalid:
              value:
                message: Merchant API token is invalid or inactive.
    NotFound:
      description: Record not found or not owned by this merchant.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/MessageResponse'
    ValidationError:
      description: Request validation failed or the merchant is not enabled for the market.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ValidationErrorResponse'
    QuotaExceeded:
      description: Merchant quota exceeded. Obey the Retry-After response header.
      headers:
        Retry-After:
          description: Seconds until the client may retry.
          schema:
            type: integer
            minimum: 1
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/QuotaExceededResponse'
  schemas:
    MessageResponse:
      type: object
      required: [message]
      properties:
        message:
          type: string
    ValidationErrorResponse:
      type: object
      required: [message]
      properties:
        message:
          type: string
        errors:
          type: object
          additionalProperties:
            type: array
            items:
              type: string
    QuotaExceededResponse:
      type: object
      required: [message, error]
      properties:
        message:
          type: string
          const: Merchant API quota exceeded.
        error:
          type: object
          required: [code, scope, limit, window, max, current, retry_after_seconds, resets_at]
          properties:
            code:
              type: string
              const: merchant_api_quota_exceeded
            scope:
              type: string
              enum: [feeds.import, offers.store, offers.bulk]
            limit:
              type: string
            window:
              type: string
              enum: [minute, hour, day]
            max:
              type: integer
            current:
              type: integer
            retry_after_seconds:
              type: integer
            resets_at:
              type: string
              format: date-time
    PaginationMeta:
      type: object
      required: [current_page, last_page, per_page, total]
      properties:
        current_page:
          type: integer
        last_page:
          type: integer
        per_page:
          type: integer
        total:
          type: integer
    Country:
      type: object
      required: [iso2, name]
      properties:
        iso2:
          type: [string, 'null']
        name:
          type: [string, 'null']
    Currency:
      type: object
      required: [code]
      properties:
        code:
          type: [string, 'null']
    AccountResponse:
      type: object
      required: [data]
      properties:
        data:
          type: object
          required: [merchant, markets, counters]
          properties:
            merchant:
              type: object
              required: [id, name, slug, status, website_url, preferred_language]
              properties:
                id: { type: integer }
                name: { type: string }
                slug: { type: string }
                status: { type: string }
                website_url: { type: [string, 'null'], format: uri }
                preferred_language:
                  oneOf:
                    - type: 'null'
                    - type: object
                      required: [code, name]
                      properties:
                        code: { type: string }
                        name: { type: string }
            markets:
              type: array
              items:
                type: object
                required: [country, currency, local_name, local_website_url]
                properties:
                  country: { $ref: '#/components/schemas/Country' }
                  currency: { $ref: '#/components/schemas/Currency' }
                  local_name: { type: [string, 'null'] }
                  local_website_url: { type: [string, 'null'], format: uri }
            counters:
              type: object
              required: [feeds, offers, import_jobs]
              properties:
                feeds: { type: integer }
                offers: { type: integer }
                import_jobs: { type: integer }
    FeedCreateRequest:
      type: object
      additionalProperties: false
      required: [name, country, format, feed_url]
      properties:
        name:
          type: string
          maxLength: 255
        country:
          type: string
          minLength: 2
          maxLength: 2
          pattern: '^[A-Za-z]{2}$'
        format:
          type: string
          enum: [xml, csv, json]
        feed_url:
          type: string
          format: uri
          maxLength: 2048
          description: Public HTTP(S) URL. Private, loopback, link-local, and otherwise unsafe targets are rejected.
        mapping_preset:
          type: string
          maxLength: 64
          default: priceinfo
          examples: [priceinfo, salidzini_like, heureka_like, ceneo_like, custom]
        field_mapping_json:
          type: [object, 'null']
          additionalProperties: true
        schedule_cron:
          type: [string, 'null']
          maxLength: 120
    FeedUpdateRequest:
      type: object
      additionalProperties: false
      minProperties: 1
      properties:
        name: { type: string, maxLength: 255 }
        country: { type: string, minLength: 2, maxLength: 2, pattern: '^[A-Za-z]{2}$' }
        format: { type: string, enum: [xml, csv, json] }
        feed_url: { type: string, format: uri, maxLength: 2048 }
        mapping_preset: { type: string, maxLength: 64 }
        field_mapping_json:
          type: [object, 'null']
          additionalProperties: true
        schedule_cron: { type: [string, 'null'], maxLength: 120 }
        status: { type: string, enum: [active, paused, pending] }
    Feed:
      type: object
      required: [id, name, feed_type, format, mapping_preset, feed_url, schedule_cron, status, market, last_import, created_at, updated_at]
      properties:
        id: { type: integer }
        name: { type: string }
        feed_type: { type: string, enum: [hosted_url, upload, direct_api] }
        format: { type: string, enum: [xml, csv, json] }
        mapping_preset: { type: [string, 'null'] }
        feed_url: { type: [string, 'null'], format: uri }
        schedule_cron: { type: [string, 'null'] }
        status: { type: string, enum: [active, paused, pending] }
        market:
          type: object
          required: [country, currency]
          properties:
            country: { $ref: '#/components/schemas/Country' }
            currency: { $ref: '#/components/schemas/Currency' }
        last_import:
          type: object
          required: [status, error, imported_at, job_id]
          properties:
            status: { type: [string, 'null'] }
            error: { type: [string, 'null'] }
            imported_at: { type: [string, 'null'], format: date-time }
            job_id: { type: [integer, 'null'] }
        created_at: { type: [string, 'null'], format: date-time }
        updated_at: { type: [string, 'null'], format: date-time }
    FeedResponse:
      type: object
      required: [data]
      properties:
        data:
          $ref: '#/components/schemas/Feed'
    DirectOffer:
      type: object
      required: [name, url, price]
      properties:
        action:
          type: [string, 'null']
          enum: [upsert, offline, null]
          default: upsert
        name: { type: string, maxLength: 500 }
        url: { type: string, format: uri, maxLength: 2048 }
        price: { type: number, minimum: 0 }
        currency: { type: [string, 'null'], minLength: 3, maxLength: 3 }
        old_price: { type: [number, 'null'], minimum: 0 }
        category: { type: [string, 'null'], maxLength: 500 }
        brand: { type: [string, 'null'], maxLength: 255 }
        model: { type: [string, 'null'], maxLength: 255 }
        gtin: { type: [string, 'null'], maxLength: 32 }
        mpn: { type: [string, 'null'], maxLength: 255 }
        image_url: { type: [string, 'null'], format: uri, maxLength: 2048 }
        availability: { type: [string, 'null'], maxLength: 120 }
        delivery_price: { type: [number, 'null'], minimum: 0 }
        delivery_time: { type: [string, 'null'], maxLength: 120 }
        _meta:
          type: object
          additionalProperties: true
          properties:
            opencart_product_id: { type: [integer, 'null'], minimum: 1 }
            cms: { type: [string, 'null'], maxLength: 80 }
            country: { type: [string, 'null'], minLength: 2, maxLength: 2 }
    SingleOfferRequest:
      allOf:
        - $ref: '#/components/schemas/DirectOffer'
        - type: object
          required: [country]
          properties:
            country:
              type: string
              minLength: 2
              maxLength: 2
              pattern: '^[A-Za-z]{2}$'
    BulkOfferRequest:
      type: object
      additionalProperties: false
      required: [country, items]
      properties:
        country:
          type: string
          minLength: 2
          maxLength: 2
          pattern: '^[A-Za-z]{2}$'
        items:
          type: array
          minItems: 1
          maxItems: 100
          items:
            $ref: '#/components/schemas/DirectOffer'
    JobFeed:
      type: object
      required: [id, name]
      properties:
        id: { type: [integer, 'null'] }
        name: { type: [string, 'null'] }
        feed_type: { type: [string, 'null'] }
    ImportCounters:
      type: object
      required: [total_records, processed_records, created_records, updated_records, review_records, rejected_records, error_count]
      properties:
        total_records: { type: integer, minimum: 0 }
        processed_records: { type: integer, minimum: 0 }
        created_records: { type: integer, minimum: 0 }
        updated_records: { type: integer, minimum: 0 }
        review_records: { type: integer, minimum: 0 }
        rejected_records: { type: integer, minimum: 0 }
        error_count: { type: integer, minimum: 0 }
    ImportJob:
      type: object
      required: [id, type, source, status, pipeline_version, feed, country, counters, summary, started_at, finished_at]
      properties:
        id: { type: integer }
        type: { type: string, enum: [merchant_feed, merchant_direct_api] }
        source: { type: string }
        status:
          type: string
          enum: [waiting_capacity, queued, preparing, running, completed, completed_with_errors, failed]
        pipeline_version:
          type: [string, 'null']
          enum: [v1, v2, null]
        feed: { $ref: '#/components/schemas/JobFeed' }
        country: { $ref: '#/components/schemas/Country' }
        counters: { $ref: '#/components/schemas/ImportCounters' }
        source_file_url: { type: [string, 'null'] }
        summary:
          type: [object, 'null']
          additionalProperties: true
        started_at: { type: [string, 'null'], format: date-time }
        finished_at: { type: [string, 'null'], format: date-time }
        created_at: { type: [string, 'null'], format: date-time }
        updated_at: { type: [string, 'null'], format: date-time }
    ImportJobResponse:
      type: object
      required: [data]
      properties:
        data:
          $ref: '#/components/schemas/ImportJob'
    AcceptedImportJobResponse:
      allOf:
        - $ref: '#/components/schemas/ImportJobResponse'
        - type: object
          required: [meta]
          properties:
            meta:
              type: object
              required: [accepted, idempotency_replay]
              properties:
                accepted: { type: boolean, const: true }
                idempotency_replay: { type: boolean }
    ImportError:
      type: object
      required: [id, severity, record_reference, error_code, message, raw_payload, created_at]
      properties:
        id: { type: integer }
        severity: { type: string }
        record_reference: { type: [string, 'null'] }
        error_code: { type: string }
        message: { type: string }
        raw_payload:
          type: [object, array, 'null']
        created_at: { type: [string, 'null'], format: date-time }
