openapi: 3.1.0
info:
  title: OBX Ingest API (write side)
  version: 0.1.0
  summary: How a brand's own systems publish content to any OBX node.
  description: |
    **Normative.** The OBX Ingest API lets a brand's automation (PIM/ERP/DAM connector,
    spreadsheet export, the `obx` CLI, a GitHub Action, n8n/Make/Zapier) publish to ANY
    conforming node. It is keyed by the brand's own ids, so every write is an idempotent
    upsert: send the same thing twice and nothing changes (and no change entry is written).

    See `docs/ingest.md` for the full semantics, including exactly how writes appear in the
    OBX Protocol `/changes` feed, and `docs/import-format.md` for the canonical CSV and
    image filename pattern.

    Authentication: `Authorization: Bearer <ingest token>` (tokens start with `obxi_`),
    created in the node admin. Scopes: `ingest:read`, `ingest:write`, `ingest:delete`.
    A token MAY be restricted to some brands; writes outside them fail with 403 `forbidden`.

    Errors are `application/problem+json` with `obx_error` (see `docs/protocol.md#errors`).
    Unknown fields in request bodies MUST be ignored by nodes (forward compatibility) and
    reported as a warning in the result.
  license:
    name: CC-BY-4.0
    url: https://creativecommons.org/licenses/by/4.0/
  contact:
    name: Open Brand Exchange
    url: https://openbrandexchange.com
servers:
  - url: https://{host}/obx/v1/ingest
    variables:
      host:
        default: brand.example.org
security:
  - ingestToken: []
tags:
  - name: Info
    description: Token info, limits and rendition requirements.
  - name: Catalog
    description: Idempotent upserts of brands, products, variants and asset metadata, keyed by external id.
  - name: Files
    description: Binary uploads with sha256 dedupe, including resumable multipart uploads.
  - name: Batch
    description: Many operations in one request.
  - name: Imports
    description: Canonical CSV plus images as one job.
  - name: Jobs
    description: Status of asynchronous work.
paths:
  /:
    get:
      tags:
        - Info
      operationId: getIngestInfo
      summary: Token info, limits and rendition requirements
      description: Any scope. Clients SHOULD call this first; `renditions.server_generated=false` means the client must upload web/square/thumb itself.
      responses:
        "200":
          description: Ingest info.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/IngestInfo"
              examples:
                info:
                  summary: Node that needs client-made versions
                  value:
                    token:
                      id: tok_1
                      name: PIM nightly
                      scopes:
                        - ingest:read
                        - ingest:write
                      brand_external_ids: null
                      expires_at: null
                    limits:
                      max_upload_bytes: 104857600
                      max_object_bytes: 2147483648
                      part_bytes: 8388608
                      max_batch_operations: 1000
                      max_import_bytes: 2147483648
                    renditions:
                      server_generated: false
                      required:
                        - original
                        - web
                        - square
                        - thumb
                    accepted_content_types:
                      - image/jpeg
                      - image/png
                      - image/webp
                      - image/tiff
                    default_status: published
                    brands:
                      - external_id: kestrel
                        name: Kestrel
                    languages:
                      - nl
                      - en
        "401":
          $ref: "#/components/responses/Unauthorized"
  /brands/{external_id}:
    parameters:
      - $ref: "#/components/parameters/ExternalId"
    get:
      tags:
        - Catalog
      operationId: getIngestBrand
      summary: Read a brand
      responses:
        "200":
          $ref: "#/components/responses/Record"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    put:
      tags:
        - Catalog
      operationId: putIngestBrand
      summary: Create or replace a brand
      parameters:
        - $ref: "#/components/parameters/IfMatch"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/BrandInput"
            examples:
              brand:
                summary: A brand
                value:
                  name: Kestrel Trail
                  website: https://kestrel.example/trail
                  description:
                    en: Trail running gear.
                    nl: Trailrunningkleding.
      responses:
        "200":
          $ref: "#/components/responses/Result"
        "201":
          $ref: "#/components/responses/ResultCreated"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "412":
          $ref: "#/components/responses/PreconditionFailed"
        "422":
          $ref: "#/components/responses/ValidationFailed"
        "429":
          $ref: "#/components/responses/TooManyRequests"
    patch:
      tags:
        - Catalog
      operationId: patchIngestBrand
      summary: Partially update a brand (JSON merge patch)
      parameters:
        - $ref: "#/components/parameters/IfMatch"
      requestBody:
        required: true
        content:
          application/merge-patch+json:
            schema:
              type: object
      responses:
        "200":
          $ref: "#/components/responses/Result"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "412":
          $ref: "#/components/responses/PreconditionFailed"
        "422":
          $ref: "#/components/responses/ValidationFailed"
    delete:
      tags:
        - Catalog
      operationId: deleteIngestBrand
      summary: Delete a brand
      description: "Requires scope ingest:delete. Fails with 409 `conflict` while the brand still has products (delete or move them first). Idempotent: deleting an unknown id returns 200 with result `unchanged`."
      responses:
        "200":
          $ref: "#/components/responses/Result"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "409":
          $ref: "#/components/responses/Conflict"
  /products/{external_id}:
    parameters:
      - $ref: "#/components/parameters/ExternalId"
    get:
      tags:
        - Catalog
      operationId: getIngestProduct
      summary: Read a product
      responses:
        "200":
          $ref: "#/components/responses/Record"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    put:
      tags:
        - Catalog
      operationId: putIngestProduct
      summary: Create or replace a product
      description: |
        Full replacement: omitted optional fields become null. Exception: an omitted `status`
        keeps the current status (new objects get the node's default status, normally
        `published`), so a nightly sync never re-publishes what someone unpublished by hand
        unless it sends `status` explicitly.
      parameters:
        - $ref: "#/components/parameters/IfMatch"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ProductInput"
            examples:
              minimal:
                summary: A minimal product
                value:
                  style_number: KO-1007
                  name:
                    en: Linen Shirt
      responses:
        "200":
          $ref: "#/components/responses/Result"
        "201":
          $ref: "#/components/responses/ResultCreated"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "412":
          $ref: "#/components/responses/PreconditionFailed"
        "422":
          $ref: "#/components/responses/ValidationFailed"
        "429":
          $ref: "#/components/responses/TooManyRequests"
    patch:
      tags:
        - Catalog
      operationId: patchIngestProduct
      summary: Partially update a product (JSON merge patch), e.g. to unpublish
      parameters:
        - $ref: "#/components/parameters/IfMatch"
      requestBody:
        required: true
        content:
          application/merge-patch+json:
            schema:
              type: object
      responses:
        "200":
          $ref: "#/components/responses/Result"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "412":
          $ref: "#/components/responses/PreconditionFailed"
        "422":
          $ref: "#/components/responses/ValidationFailed"
    delete:
      tags:
        - Catalog
      operationId: deleteIngestProduct
      summary: Delete a product with its variants and assets
      description: Requires ingest:delete. Emits tombstones for the product and each of its visible variants and assets. Idempotent.
      responses:
        "200":
          $ref: "#/components/responses/Result"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
  /products/{external_id}/asset-order:
    put:
      tags:
        - Catalog
      operationId: putIngestAssetOrder
      summary: Set the image order of one product colour atomically
      description: |
        Assigns positions 1..n to the listed assets (all non-deleted assets of this product
        and `color_code` MUST be listed; 422 `validation_failed` otherwise). This is how you
        swap or reorder images without `position_conflict`. Emits one asset upsert per asset
        whose position changed.
      parameters:
        - $ref: "#/components/parameters/ExternalId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - color_code
                - asset_external_ids
              properties:
                color_code:
                  type:
                    - string
                    - "null"
                  description: null = the product-wide (all colours) assets.
                asset_external_ids:
                  type: array
                  minItems: 1
                  maxItems: 999
                  uniqueItems: true
                  items:
                    $ref: "#/components/schemas/ExternalId"
      responses:
        "200":
          description: One result per listed asset.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/IngestResult"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/ValidationFailed"
  /variants/{external_id}:
    parameters:
      - $ref: "#/components/parameters/ExternalId"
    get:
      tags:
        - Catalog
      operationId: getIngestVariant
      summary: Read a variant
      responses:
        "200":
          $ref: "#/components/responses/Record"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    put:
      tags:
        - Catalog
      operationId: putIngestVariant
      summary: Create or replace a variant
      description: The product (`product_external_id`) MUST exist, else 422 `unknown_parent`. GTIN check digit MUST be valid (`invalid_gtin`) and unique in the node (409 `gtin_conflict`).
      parameters:
        - $ref: "#/components/parameters/IfMatch"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/VariantInput"
            examples:
              variant:
                summary: A size with EAN
                value:
                  product_external_id: KO-1007
                  gtin: "2000100701010"
                  color:
                    code: SND
                    name:
                      en: Sand
                  size: M
                  size_system: INT
      responses:
        "200":
          $ref: "#/components/responses/Result"
        "201":
          $ref: "#/components/responses/ResultCreated"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "409":
          $ref: "#/components/responses/Conflict"
        "412":
          $ref: "#/components/responses/PreconditionFailed"
        "422":
          $ref: "#/components/responses/ValidationFailed"
        "429":
          $ref: "#/components/responses/TooManyRequests"
    patch:
      tags:
        - Catalog
      operationId: patchIngestVariant
      summary: Partially update a variant (JSON merge patch)
      parameters:
        - $ref: "#/components/parameters/IfMatch"
      requestBody:
        required: true
        content:
          application/merge-patch+json:
            schema:
              type: object
      responses:
        "200":
          $ref: "#/components/responses/Result"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "422":
          $ref: "#/components/responses/ValidationFailed"
    delete:
      tags:
        - Catalog
      operationId: deleteIngestVariant
      summary: Delete a variant
      responses:
        "200":
          $ref: "#/components/responses/Result"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
  /assets/{external_id}:
    parameters:
      - $ref: "#/components/parameters/ExternalId"
    get:
      tags:
        - Catalog
      operationId: getIngestAsset
      summary: Read asset metadata (including missing_files)
      responses:
        "200":
          $ref: "#/components/responses/Record"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    put:
      tags:
        - Catalog
      operationId: putIngestAsset
      summary: Create or replace asset metadata
      description: |
        The product MUST exist (`unknown_parent`). `position` MUST be free among the product's
        other non-deleted assets of the same `color_code` (409 `position_conflict`; replacing an
        image = reuse its external_id). `files` attaches blobs uploaded earlier (422
        `blob_missing` if a sha256 is unknown); this is the atomic way to replace an original
        together with client-generated renditions. The asset becomes visible once its original
        and all required renditions exist (`visibility: pending_files` until then).
      parameters:
        - $ref: "#/components/parameters/IfMatch"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AssetInput"
            examples:
              client:
                summary: Image with client-made versions
                value:
                  product_external_id: KO-1007
                  color_code: SND
                  role: packshot
                  view: front
                  position: 1
                  rights:
                    channels:
                      - webshop
                    embargo_until: 2027-02-01T00:00:00Z
                  provenance:
                    ai_generated: false
                    ai_edited: true
                    source: composite
                  files:
                    original:
                      sha256: "6666666666666666666666666666666666666666666666666666666666666666"
                    web:
                      sha256: "7777777777777777777777777777777777777777777777777777777777777777"
                      width: 1600
                      height: 2000
                    square:
                      sha256: "8888888888888888888888888888888888888888888888888888888888888888"
                      width: 2000
                      height: 2000
                    thumb:
                      sha256: "9999999999999999999999999999999999999999999999999999999999999999"
                      width: 480
                      height: 600
      responses:
        "200":
          $ref: "#/components/responses/Result"
        "201":
          $ref: "#/components/responses/ResultCreated"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "409":
          $ref: "#/components/responses/Conflict"
        "412":
          $ref: "#/components/responses/PreconditionFailed"
        "422":
          $ref: "#/components/responses/ValidationFailed"
        "429":
          $ref: "#/components/responses/TooManyRequests"
    patch:
      tags:
        - Catalog
      operationId: patchIngestAsset
      summary: Partially update asset metadata (JSON merge patch)
      parameters:
        - $ref: "#/components/parameters/IfMatch"
      requestBody:
        required: true
        content:
          application/merge-patch+json:
            schema:
              type: object
      responses:
        "200":
          $ref: "#/components/responses/Result"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "422":
          $ref: "#/components/responses/ValidationFailed"
    delete:
      tags:
        - Catalog
      operationId: deleteIngestAsset
      summary: Delete an asset
      responses:
        "200":
          $ref: "#/components/responses/Result"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
  /assets/{external_id}/files/{rendition}:
    put:
      tags:
        - Files
      operationId: putIngestAssetFile
      summary: Upload the original or a client-generated rendition
      description: |
        Raw body, `Content-Type` of the file. `OBX-Content-SHA256` is RECOMMENDED; when present
        the node verifies it (422 `checksum_mismatch`). The asset metadata MUST exist (404).
        Uploading the same bytes again is a no-op (`unchanged`).

        Replacing `original` on a node without server-side renditions: the node keeps serving
        the previous original and renditions until all required renditions uploaded AFTER the
        new original are present, then swaps atomically (one asset upsert in /changes).
        Renditions MUST follow the rendition rules (web/thumb: JPEG, longest side <= 2000/600,
        never upscaled; square: web on a white square canvas); nodes MAY reject (422
        `validation_failed`) renditions whose size violates them.
      parameters:
        - $ref: "#/components/parameters/ExternalId"
        - name: rendition
          in: path
          required: true
          schema:
            $ref: "#/components/schemas/RenditionName"
        - $ref: "#/components/parameters/ContentSha256"
      requestBody:
        required: true
        content:
          image/*:
            schema:
              type: string
              contentMediaType: application/octet-stream
      responses:
        "200":
          $ref: "#/components/responses/Result"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/ValidationFailed"
  /blobs/{sha256}:
    parameters:
      - name: sha256
        in: path
        required: true
        schema:
          $ref: "#/components/schemas/Sha256"
    head:
      tags:
        - Files
      operationId: headIngestBlob
      summary: Does the node already have these bytes?
      description: Dedupe check before uploading. 200 = present (skip the upload), 404 = upload it.
      responses:
        "200":
          description: Present.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: Not present.
    put:
      tags:
        - Files
      operationId: putIngestBlob
      summary: Upload bytes (content-addressed)
      description: Raw body. The node verifies the sha256 (422 `checksum_mismatch`). Blobs not referenced by any asset MAY be garbage-collected after 24 h. Bodies larger than `limits.max_upload_bytes` use /uploads.
      requestBody:
        required: true
        content:
          image/*:
            schema:
              type: string
              contentMediaType: application/octet-stream
      responses:
        "200":
          description: Already present (created=false).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Blob"
              examples:
                blob:
                  summary: Stored blob
                  value:
                    sha256: "3333333333333333333333333333333333333333333333333333333333333333"
                    bytes: 48211
                    content_type: image/jpeg
                    width: 1200
                    height: 1500
                    created: true
        "201":
          description: Stored.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Blob"
              examples:
                blob:
                  summary: Stored blob
                  value:
                    sha256: "3333333333333333333333333333333333333333333333333333333333333333"
                    bytes: 48211
                    content_type: image/jpeg
                    width: 1200
                    height: 1500
                    created: true
        "401":
          $ref: "#/components/responses/Unauthorized"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/ValidationFailed"
  /uploads:
    post:
      tags:
        - Files
      operationId: createUpload
      summary: Start a resumable multipart upload
      description: For files above `max_upload_bytes`. If the node already has the sha256 it answers 200 with the Blob and no upload is needed.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - sha256
                - bytes
                - content_type
              properties:
                sha256:
                  $ref: "#/components/schemas/Sha256"
                bytes:
                  type: integer
                  minimum: 1
                content_type:
                  type: string
      responses:
        "200":
          description: Blob already present.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Blob"
              examples:
                blob:
                  summary: Stored blob
                  value:
                    sha256: "3333333333333333333333333333333333333333333333333333333333333333"
                    bytes: 48211
                    content_type: image/jpeg
                    width: 1200
                    height: 1500
                    created: true
        "201":
          description: Upload session created.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Upload"
              examples:
                upload:
                  summary: Multipart upload in progress
                  value:
                    id: upl_1
                    sha256: "4444444444444444444444444444444444444444444444444444444444444444"
                    bytes: 83886080
                    content_type: image/tiff
                    part_bytes: 8388608
                    parts_total: 10
                    received_parts:
                      - 1
                      - 2
                      - 3
                    status: open
                    expires_at: 2026-09-29T10:00:00Z
        "401":
          $ref: "#/components/responses/Unauthorized"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
  /uploads/{id}:
    parameters:
      - $ref: "#/components/parameters/IdPath"
    get:
      tags:
        - Files
      operationId: getUpload
      summary: Upload status (which parts arrived)
      responses:
        "200":
          description: Upload session.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Upload"
              examples:
                upload:
                  summary: Multipart upload in progress
                  value:
                    id: upl_1
                    sha256: "4444444444444444444444444444444444444444444444444444444444444444"
                    bytes: 83886080
                    content_type: image/tiff
                    part_bytes: 8388608
                    parts_total: 10
                    received_parts:
                      - 1
                      - 2
                      - 3
                    status: open
                    expires_at: 2026-09-29T10:00:00Z
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    delete:
      tags:
        - Files
      operationId: abortUpload
      summary: Abort an upload
      responses:
        "204":
          description: Aborted.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
  /uploads/{id}/parts/{number}:
    put:
      tags:
        - Files
      operationId: putUploadPart
      summary: Upload one part
      description: Idempotent per part number; re-sending a part replaces it. Every part except the last MUST be exactly `part_bytes`.
      parameters:
        - $ref: "#/components/parameters/IdPath"
        - name: number
          in: path
          required: true
          schema:
            type: integer
            minimum: 1
      requestBody:
        required: true
        content:
          application/octet-stream:
            schema:
              type: string
              contentMediaType: application/octet-stream
      responses:
        "200":
          description: Part stored.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Upload"
              examples:
                upload:
                  summary: Multipart upload in progress
                  value:
                    id: upl_1
                    sha256: "4444444444444444444444444444444444444444444444444444444444444444"
                    bytes: 83886080
                    content_type: image/tiff
                    part_bytes: 8388608
                    parts_total: 10
                    received_parts:
                      - 1
                      - 2
                      - 3
                    status: open
                    expires_at: 2026-09-29T10:00:00Z
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "422":
          $ref: "#/components/responses/ValidationFailed"
  /uploads/{id}/complete:
    post:
      tags:
        - Files
      operationId: completeUpload
      summary: Assemble the parts into a blob
      description: Verifies size and sha256. Missing parts give 409 `upload_incomplete`; a hash mismatch gives 422 `checksum_mismatch` (the upload is aborted).
      parameters:
        - $ref: "#/components/parameters/IdPath"
      responses:
        "200":
          description: The blob.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Blob"
              examples:
                blob:
                  summary: Stored blob
                  value:
                    sha256: "3333333333333333333333333333333333333333333333333333333333333333"
                    bytes: 48211
                    content_type: image/jpeg
                    width: 1200
                    height: 1500
                    created: true
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "422":
          $ref: "#/components/responses/ValidationFailed"
  /batch:
    post:
      tags:
        - Batch
      operationId: postIngestBatch
      summary: Many operations in one request
      description: |
        Up to `limits.max_batch_operations` (>= 1000) operations, processed in order, each one
        atomic on its own; a failing operation does not stop the rest. Body: a JSON array of
        IngestOperation, or NDJSON (one operation per line). Binary data is referenced by
        sha256 (upload blobs first). The node answers 200 with all results when it processed
        the batch within the request, or 202 with a job (always when the client sends
        `Prefer: respond-async`). Clients MUST handle both.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
        - name: Prefer
          in: header
          required: false
          schema:
            type: string
            enum:
              - respond-async
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: array
              maxItems: 1000
              items:
                $ref: "#/components/schemas/IngestOperation"
          application/x-ndjson:
            schema:
              $ref: "#/components/schemas/IngestOperation"
      responses:
        "200":
          description: Processed.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BatchResponse"
        "202":
          description: Accepted as a job.
          headers:
            Location:
              schema:
                type: string
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/IngestJob"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /imports:
    post:
      tags:
        - Imports
      operationId: postIngestImport
      summary: Import a canonical CSV and/or images as one job
      description: |
        multipart/form-data with any of: `package` (zip with products.csv, optional assets.csv
        and images), `csv` (products.csv), `assets_csv` (assets.csv), `images` (one zip, or
        repeated image files), `images_manifest` (JSON: images already uploaded as blobs,
        referenced by file name + sha256, optionally with client-generated renditions) and
        `options` (JSON). The manifest is how the `obx` CLI and the admin UI avoid re-uploading
        unchanged images: HEAD /blobs/{sha256}, upload only what is missing, then import with
        the manifest. Always asynchronous: 202 + job. Row and file problems are reported as
        ImportRowError results, never as a failed request. See docs/import-format.md.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              properties:
                package:
                  type: string
                  contentMediaType: application/zip
                csv:
                  type: string
                  contentMediaType: text/csv
                assets_csv:
                  type: string
                  contentMediaType: text/csv
                images:
                  type: array
                  items:
                    type: string
                    contentMediaType: application/octet-stream
                images_manifest:
                  $ref: "#/components/schemas/ImagesManifest"
                options:
                  $ref: "#/components/schemas/ImportOptions"
            encoding:
              options:
                contentType: application/json
              images_manifest:
                contentType: application/json
      responses:
        "202":
          description: Import job queued.
          headers:
            Location:
              schema:
                type: string
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/IngestJob"
              examples:
                import:
                  summary: Import job
                  value:
                    id: job_1
                    kind: import
                    status: succeeded
                    created_at: 2026-09-28T10:00:00Z
                    finished_at: 2026-09-28T10:00:00Z
                    progress:
                      done: 67
                      total: 67
                    summary:
                      created: 75
                      updated: 0
                      unchanged: 0
                      deleted: 0
                      errors: 1
                    import_run:
                      id: run_1
                      source_id: null
                      trigger: api
                      status: succeeded
                      mode: upsert
                      dry_run: false
                      created_at: 2026-09-28T10:00:00Z
                      started_at: 2026-09-28T10:00:00Z
                      finished_at: 2026-09-28T10:00:00Z
                      files:
                        - name: products.csv
                          bytes: 10240
                          sha256: "5555555555555555555555555555555555555555555555555555555555555555"
                      counts:
                        rows: 40
                        files: 27
                        created: 75
                        updated: 0
                        unchanged: 0
                        unpublished: 0
                        errors: 1
                        warnings: 0
                      error: null
                      errors_preview:
                        - file: products.csv
                          row: 12
                          column: gtin
                          severity: error
                          obx_error: invalid_gtin
                          detail: Check digit should be 8.
                          value: "2000100101019"
                    results_url: https://brands.kestrel.example/obx/v1/ingest/jobs/job_1/results
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
  /jobs:
    get:
      tags:
        - Jobs
      operationId: listIngestJobs
      summary: Recent jobs of this token's brand node
      parameters:
        - name: status
          in: query
          schema:
            type: string
            enum:
              - queued
              - running
              - succeeded
              - failed
        - $ref: "#/components/parameters/Cursor"
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 25
      responses:
        "200":
          description: Jobs, newest first.
          content:
            application/json:
              schema:
                type: object
                required:
                  - items
                  - next_cursor
                properties:
                  items:
                    type: array
                    items:
                      $ref: "#/components/schemas/IngestJob"
                  next_cursor:
                    oneOf:
                      - $ref: "#/components/schemas/Cursor"
                      - type: "null"
        "401":
          $ref: "#/components/responses/Unauthorized"
  /jobs/{id}:
    get:
      tags:
        - Jobs
      operationId: getIngestJob
      summary: Job status
      parameters:
        - $ref: "#/components/parameters/IdPath"
      responses:
        "200":
          description: The job.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/IngestJob"
              examples:
                import:
                  summary: Import job
                  value:
                    id: job_1
                    kind: import
                    status: succeeded
                    created_at: 2026-09-28T10:00:00Z
                    finished_at: 2026-09-28T10:00:00Z
                    progress:
                      done: 67
                      total: 67
                    summary:
                      created: 75
                      updated: 0
                      unchanged: 0
                      deleted: 0
                      errors: 1
                    import_run:
                      id: run_1
                      source_id: null
                      trigger: api
                      status: succeeded
                      mode: upsert
                      dry_run: false
                      created_at: 2026-09-28T10:00:00Z
                      started_at: 2026-09-28T10:00:00Z
                      finished_at: 2026-09-28T10:00:00Z
                      files:
                        - name: products.csv
                          bytes: 10240
                          sha256: "5555555555555555555555555555555555555555555555555555555555555555"
                      counts:
                        rows: 40
                        files: 27
                        created: 75
                        updated: 0
                        unchanged: 0
                        unpublished: 0
                        errors: 1
                        warnings: 0
                      error: null
                      errors_preview:
                        - file: products.csv
                          row: 12
                          column: gtin
                          severity: error
                          obx_error: invalid_gtin
                          detail: Check digit should be 8.
                          value: "2000100101019"
                    results_url: https://brands.kestrel.example/obx/v1/ingest/jobs/job_1/results
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
  /jobs/{id}/results:
    get:
      tags:
        - Jobs
      operationId: listIngestJobResults
      summary: Per-operation (batch) or per-row (import) results
      parameters:
        - $ref: "#/components/parameters/IdPath"
        - name: errors_only
          in: query
          schema:
            type: boolean
            default: false
        - $ref: "#/components/parameters/Cursor"
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 1000
            default: 100
      responses:
        "200":
          description: Results page. Batch jobs return IngestResult items; import jobs ImportRowError items.
          content:
            application/json:
              schema:
                type: object
                required:
                  - items
                  - next_cursor
                properties:
                  items:
                    type: array
                    items:
                      anyOf:
                        - $ref: "#/components/schemas/IngestResult"
                        - $ref: "#/components/schemas/ImportRowError"
                  next_cursor:
                    oneOf:
                      - $ref: "#/components/schemas/Cursor"
                      - type: "null"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
components:
  securitySchemes:
    ingestToken:
      type: http
      scheme: bearer
      description: Brand ingest token (obxi_...), created and revoked in the node admin.
  parameters:
    ExternalId:
      name: external_id
      in: path
      required: true
      description: The brand's own id, percent-encoded.
      schema:
        $ref: "#/components/schemas/ExternalId"
    IdPath:
      name: id
      in: path
      required: true
      schema:
        $ref: "#/components/schemas/Id"
    Cursor:
      name: cursor
      in: query
      required: false
      schema:
        $ref: "#/components/schemas/Cursor"
    IfMatch:
      name: If-Match
      in: header
      required: false
      description: Optional optimistic concurrency with the record's ETag.
      schema:
        type: string
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: Repeating a request with the same key within 24 h returns the original response (same job).
      schema:
        type: string
        maxLength: 200
    ContentSha256:
      name: OBX-Content-SHA256
      in: header
      required: false
      description: Lowercase hex sha256 of the body; verified when present.
      schema:
        $ref: "#/components/schemas/Sha256"
  schemas:
    BatchResponse:
      type: object
      required:
        - results
        - summary
      properties:
        results:
          type: array
          items:
            $ref: "#/components/schemas/IngestResult"
        summary:
          type: object
          required:
            - created
            - updated
            - unchanged
            - deleted
            - errors
          properties:
            created:
              type: integer
              minimum: 0
            updated:
              type: integer
              minimum: 0
            unchanged:
              type: integer
              minimum: 0
            deleted:
              type: integer
              minimum: 0
            errors:
              type: integer
              minimum: 0
    ImagesManifest:
      type: array
      maxItems: 100000
      description: Images already uploaded as blobs. `file` is the image file name the canonical filename pattern and assets.csv refer to. Unknown sha256 -> row error `blob_missing`.
      items:
        type: object
        required:
          - file
          - sha256
        properties:
          file:
            type: string
            maxLength: 255
          sha256:
            $ref: "#/components/schemas/Sha256"
          width:
            type: integer
            minimum: 1
          height:
            type: integer
            minimum: 1
          renditions:
            type: object
            description: Client-generated renditions (required on nodes without server-side renditions).
            additionalProperties: false
            properties:
              web:
                $ref: "#/components/schemas/BlobRef"
              square:
                $ref: "#/components/schemas/BlobRef"
              thumb:
                $ref: "#/components/schemas/BlobRef"
    BlobRef:
      type: object
      required:
        - sha256
        - width
        - height
      properties:
        sha256:
          $ref: "#/components/schemas/Sha256"
        width:
          type: integer
          minimum: 1
        height:
          type: integer
          minimum: 1
    ImportOptions:
      type: object
      properties:
        mode:
          type: string
          enum:
            - upsert
            - mirror
          default: upsert
          description: mirror also unpublishes products of the brands present in the file that the file does not contain.
        allow_mass_unpublish:
          type: boolean
          default: false
          description: Without it a mirror import that would unpublish more than half of those products fails with mass_unpublish_refused.
        dry_run:
          type: boolean
          default: false
          description: Validate and report only; write nothing.
        default_language:
          description: Language of un-suffixed text columns (name, description, color_name). Default = the node's first language.
          $ref: "#/components/schemas/LanguageTag"
    Id:
      type: string
      description: Opaque, node-assigned identifier. Stable for the life of the object. Consumers MUST treat it as opaque.
      pattern: ^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$
      examples:
        - prd_01J9ZK3Q7T5W
    IngestScope:
      type: string
      enum:
        - ingest:read
        - ingest:write
        - ingest:delete
      description: OBX Ingest token scope.
    ExternalId:
      type: string
      description: The brand's own identifier for an object (from its PIM/ERP/spreadsheet). 1-200 characters, no control characters and no '/'. Leading/trailing whitespace is trimmed by nodes. Compared case-sensitively.
      minLength: 1
      maxLength: 200
      pattern: ^[^\x00-\x1F\x7F/]+$
      examples:
        - KO-1001
    DateTime:
      type: string
      description: RFC 3339 timestamp, UTC recommended.
      format: date-time
      examples:
        - 2026-09-28T10:00:00Z
    RenditionName:
      type: string
      enum:
        - original
        - web
        - square
        - thumb
      description: Rendition name.
    PublicationStatus:
      type: string
      enum:
        - published
        - unpublished
      description: Brand-side publication state. Only 'published' objects (with published parents) are visible through the OBX Protocol.
    LanguageTag:
      type: string
      description: BCP 47 language tag, restricted to language or language-REGION.
      pattern: ^[a-z]{2,3}(-[A-Z]{2})?$
      examples:
        - nl
        - en
        - de-CH
    IngestInfo:
      $schema: https://json-schema.org/draft/2020-12/schema
      $id: https://openbrandexchange.com/schemas/v0.1/IngestInfo.json
      type: object
      title: IngestInfo
      description: "GET /obx/v1/ingest: who am I, limits, and whether clients must generate renditions."
      properties:
        token:
          type: object
          properties:
            id:
              $ref: "#/components/schemas/Id"
            name:
              type: string
            scopes:
              type: array
              items:
                $ref: "#/components/schemas/IngestScope"
            brand_external_ids:
              type:
                - array
                - "null"
              items:
                $ref: "#/components/schemas/ExternalId"
              description: Restriction; null = all brands.
            expires_at:
              anyOf:
                - $ref: "#/components/schemas/DateTime"
                - type: "null"
          required:
            - id
            - name
            - scopes
            - brand_external_ids
            - expires_at
          additionalProperties: false
          patternProperties:
            ^x-[a-z0-9]+-[a-z0-9_-]+$: true
        limits:
          type: object
          properties:
            max_upload_bytes:
              type: integer
              description: Largest single-request body (blob/file upload).
              minimum: 26214400
            max_object_bytes:
              type: integer
              description: Largest original via multipart upload.
              minimum: 524288000
            part_bytes:
              type: integer
              minimum: 5242880
            max_batch_operations:
              type: integer
              minimum: 1000
            max_import_bytes:
              type: integer
              minimum: 524288000
          required:
            - max_upload_bytes
            - max_object_bytes
            - part_bytes
            - max_batch_operations
            - max_import_bytes
          additionalProperties: false
          patternProperties:
            ^x-[a-z0-9]+-[a-z0-9_-]+$: true
        renditions:
          type: object
          properties:
            server_generated:
              type: boolean
              description: "true: the node renders web/square/thumb itself. false: clients MUST upload them (admin UI and obx CLI do this automatically)."
            required:
              type: array
              items:
                $ref: "#/components/schemas/RenditionName"
          required:
            - server_generated
            - required
          additionalProperties: false
          patternProperties:
            ^x-[a-z0-9]+-[a-z0-9_-]+$: true
        accepted_content_types:
          type: array
          items:
            type: string
          description: Original media types accepted, at least image/jpeg, image/png, image/webp.
        default_status:
          $ref: "#/components/schemas/PublicationStatus"
        brands:
          type: array
          items:
            type: object
            properties:
              external_id:
                $ref: "#/components/schemas/ExternalId"
              name:
                type: string
            required:
              - external_id
              - name
            additionalProperties: false
            patternProperties:
              ^x-[a-z0-9]+-[a-z0-9_-]+$: true
        languages:
          type: array
          items:
            $ref: "#/components/schemas/LanguageTag"
          minItems: 1
      required:
        - token
        - limits
        - renditions
        - accepted_content_types
        - default_status
        - brands
        - languages
      additionalProperties: false
      patternProperties:
        ^x-[a-z0-9]+-[a-z0-9_-]+$: true
    ErrorCode:
      type: string
      enum:
        - invalid_request
        - validation_failed
        - unauthorized
        - invalid_token
        - access_revoked
        - insufficient_scope
        - forbidden
        - not_found
        - method_not_allowed
        - conflict
        - precondition_failed
        - payload_too_large
        - unsupported_media_type
        - rate_limited
        - limit_exceeded
        - cursor_invalid
        - cursor_expired
        - export_expired
        - original_not_allowed
        - invalid_gtin
        - gtin_conflict
        - position_conflict
        - checksum_mismatch
        - blob_missing
        - upload_incomplete
        - unknown_parent
        - files_missing
        - import_invalid
        - mass_unpublish_refused
        - unsafe_url
        - csrf_failed
        - invalid_credentials
        - account_blocked
        - setup_required
        - setup_completed
        - invite_invalid
        - not_implemented
        - internal_error
        - service_unavailable
      description: Machine-readable OBX error code (see docs/protocol.md#errors). Clients MUST treat unknown codes by HTTP status.
    Problem:
      $schema: https://json-schema.org/draft/2020-12/schema
      $id: https://openbrandexchange.com/schemas/v0.1/Problem.json
      type: object
      title: Problem
      description: "application/problem+json (RFC 9457) error body. Exception: the OAuth2 token endpoint uses RFC 6749 error JSON."
      properties:
        type:
          type: string
          description: "URI identifying the problem type: `https://openbrandexchange.com/errors/<obx_error>`."
          format: uri
        title:
          type: string
        status:
          type: integer
          minimum: 400
          maximum: 599
        detail:
          type:
            - string
            - "null"
        instance:
          type:
            - string
            - "null"
        obx_error:
          $ref: "#/components/schemas/ErrorCode"
        errors:
          type: array
          items:
            type: object
            properties:
              pointer:
                type:
                  - string
                  - "null"
                description: JSON Pointer (RFC 6901) into the request body, or a CSV locator like 'row:12/gtin'.
              obx_error:
                $ref: "#/components/schemas/ErrorCode"
              detail:
                type: string
            required:
              - obx_error
              - detail
            additionalProperties: false
            patternProperties:
              ^x-[a-z0-9]+-[a-z0-9_-]+$: true
          description: Field-level errors (validation_failed).
        retry_after:
          type:
            - integer
            - "null"
          description: Seconds; mirrors Retry-After.
          minimum: 0
      required:
        - type
        - title
        - status
        - obx_error
      additionalProperties: false
      patternProperties:
        ^x-[a-z0-9]+-[a-z0-9_-]+$: true
    IngestRecord:
      $schema: https://json-schema.org/draft/2020-12/schema
      $id: https://openbrandexchange.com/schemas/v0.1/IngestRecord.json
      type: object
      title: IngestRecord
      description: GET representation in the Ingest API (and the admin catalog).
      properties:
        kind:
          type: string
          enum:
            - brand
            - product
            - variant
            - asset
        id:
          $ref: "#/components/schemas/Id"
        external_id:
          $ref: "#/components/schemas/ExternalId"
        status:
          $ref: "#/components/schemas/PublicationStatus"
        visibility:
          type: string
          enum:
            - visible
            - hidden
            - pending_files
        etag:
          type: string
        created_at:
          $ref: "#/components/schemas/DateTime"
        updated_at:
          $ref: "#/components/schemas/DateTime"
        data:
          type: object
          description: "The stored write model (BrandInput/ProductInput/VariantInput/AssetInput) with defaults applied. For assets: `rights` is the full effective Rights object (node default rights merged with the fields the brand sent), `provenance` the full Provenance with its defaults, and `files` lists every current blob (original and web/square/thumb, including renditions the node made itself) with sha256, width and height."
        missing_files:
          type:
            - array
            - "null"
          items:
            $ref: "#/components/schemas/RenditionName"
          description: Present for assets only (an empty array when nothing is missing); omitted for brands, products and variants. Files still required before the asset becomes visible.
      required:
        - kind
        - id
        - external_id
        - status
        - visibility
        - etag
        - created_at
        - updated_at
        - data
      additionalProperties: false
      patternProperties:
        ^x-[a-z0-9]+-[a-z0-9_-]+$: true
    Url:
      type: string
      description: Absolute http(s) URL.
      format: uri
      pattern: ^https?://
      maxLength: 2048
    LocalizedText:
      $schema: https://json-schema.org/draft/2020-12/schema
      $id: https://openbrandexchange.com/schemas/v0.1/LocalizedText.json
      title: LocalizedText
      description: 'Free text per language, keyed by BCP 47 tag. At least one language. Example: {"nl": "Trui", "en": "Sweater"}.'
      type: object
      minProperties: 1
      patternProperties:
        ^[a-z]{2,3}(-[A-Z]{2})?$:
          type: string
          minLength: 1
          maxLength: 20000
      additionalProperties: false
    BrandInput:
      $schema: https://json-schema.org/draft/2020-12/schema
      $id: https://openbrandexchange.com/schemas/v0.1/BrandInput.json
      type: object
      title: BrandInput
      description: "Ingest write model for a brand. Key: the brand's external_id (path)."
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 200
        logo_url:
          anyOf:
            - $ref: "#/components/schemas/Url"
              description: Fetched by the node once (SSRF-protected) or null.
            - type: "null"
        website:
          anyOf:
            - $ref: "#/components/schemas/Url"
            - type: "null"
        description:
          anyOf:
            - $ref: "#/components/schemas/LocalizedText"
            - type: "null"
        status:
          $ref: "#/components/schemas/PublicationStatus"
      required:
        - name
      additionalProperties: false
      patternProperties:
        ^x-[a-z0-9]+-[a-z0-9_-]+$: true
    IngestResult:
      $schema: https://json-schema.org/draft/2020-12/schema
      $id: https://openbrandexchange.com/schemas/v0.1/IngestResult.json
      type: object
      title: IngestResult
      properties:
        index:
          type:
            - integer
            - "null"
          description: 0-based position in the batch (batch only).
          minimum: 0
        op:
          type: string
          enum:
            - upsert
            - patch
            - delete
            - publish
            - unpublish
        kind:
          type: string
          enum:
            - brand
            - product
            - variant
            - asset
        external_id:
          $ref: "#/components/schemas/ExternalId"
        result:
          type: string
          enum:
            - created
            - updated
            - unchanged
            - deleted
            - error
          description: "unchanged: the stored representation is identical; no change entry was written."
        id:
          anyOf:
            - $ref: "#/components/schemas/Id"
              description: Protocol id of the object (null on error before creation).
            - type: "null"
        visibility:
          type:
            - string
            - "null"
          enum:
            - visible
            - hidden
            - pending_files
            - null
          description: "visible: served by the protocol. hidden: unpublished or a parent is hidden/missing. pending_files: asset waiting for its original or required renditions."
        etag:
          type:
            - string
            - "null"
        error:
          anyOf:
            - $ref: "#/components/schemas/Problem"
            - type: "null"
        warnings:
          type: array
          items:
            type: string
            maxLength: 1000
      required:
        - op
        - kind
        - external_id
        - result
      additionalProperties: false
      patternProperties:
        ^x-[a-z0-9]+-[a-z0-9_-]+$: true
    Materials:
      $schema: https://json-schema.org/draft/2020-12/schema
      $id: https://openbrandexchange.com/schemas/v0.1/Materials.json
      type: array
      items:
        type: object
        properties:
          part:
            type: string
            description: "Garment part. Recommended keys: main, outer, lining, filling, trim, sole, upper, insole. 'main' when the product has one composition."
            pattern: ^[a-z][a-z0-9_]{0,63}$
            examples:
              - main
          fibers:
            type: array
            items:
              type: object
              properties:
                fiber:
                  type: string
                  description: Fibre key, lowercase English (recommended list in docs/import-format.md#fibres), e.g. cotton, organic_cotton, elastane, wool, merino_wool, polyester, recycled_polyester, polyamide, viscose, linen, silk, leather.
                  pattern: ^[a-z][a-z0-9_]{0,63}$
                percentage:
                  type: number
                  exclusiveMinimum: 0
                  maximum: 100
              required:
                - fiber
                - percentage
              additionalProperties: false
              patternProperties:
                ^x-[a-z0-9]+-[a-z0-9_-]+$: true
            minItems: 1
            description: Percentages of one part SHOULD sum to 100.
        required:
          - part
          - fibers
        additionalProperties: false
        patternProperties:
          ^x-[a-z0-9]+-[a-z0-9_-]+$: true
      title: Materials
      description: Fibre composition per part (textile labelling regulation / DPP ready).
    Care:
      $schema: https://json-schema.org/draft/2020-12/schema
      $id: https://openbrandexchange.com/schemas/v0.1/Care.json
      type: array
      items:
        type: object
        properties:
          code:
            type: string
            description: Care instruction key, ISO 3758 aligned (recommended list in docs/import-format.md#care-codes), e.g. wash_30, do_not_bleach, tumble_dry_low, iron_low, dry_clean_p, do_not_dry_clean.
            pattern: ^[a-z][a-z0-9_]{0,63}$
          text:
            anyOf:
              - $ref: "#/components/schemas/LocalizedText"
              - type: "null"
        required:
          - code
        additionalProperties: false
        patternProperties:
          ^x-[a-z0-9]+-[a-z0-9_-]+$: true
      title: Care
      description: Ordered care instructions.
    CountryCode:
      type: string
      description: ISO 3166-1 alpha-2 country code.
      pattern: ^[A-Z]{2}$
      examples:
        - NL
    ProductInput:
      $schema: https://json-schema.org/draft/2020-12/schema
      $id: https://openbrandexchange.com/schemas/v0.1/ProductInput.json
      type: object
      title: ProductInput
      description: "Ingest write model for a product. Key: external_id (path); when a brand has no separate product id, use the style number."
      properties:
        brand_external_id:
          anyOf:
            - $ref: "#/components/schemas/ExternalId"
              description: Required unless the node has exactly one brand.
            - type: "null"
        style_number:
          type: string
          minLength: 1
          maxLength: 200
        name:
          $ref: "#/components/schemas/LocalizedText"
        description:
          anyOf:
            - $ref: "#/components/schemas/LocalizedText"
            - type: "null"
        item_type:
          type:
            - string
            - "null"
          pattern: ^[a-z][a-z0-9_]{0,63}$
        gpc_brick:
          type:
            - string
            - "null"
          pattern: ^[0-9]{8}$
        target_group:
          type:
            - string
            - "null"
          pattern: ^[a-z][a-z0-9_]{0,63}$
        season:
          type:
            - string
            - "null"
          maxLength: 64
        collection:
          type:
            - string
            - "null"
          maxLength: 200
        materials:
          anyOf:
            - $ref: "#/components/schemas/Materials"
            - type: "null"
        care:
          anyOf:
            - $ref: "#/components/schemas/Care"
            - type: "null"
        country_of_origin:
          anyOf:
            - $ref: "#/components/schemas/CountryCode"
            - type: "null"
        dpp_url:
          anyOf:
            - $ref: "#/components/schemas/Url"
            - type: "null"
        attributes:
          type:
            - object
            - "null"
          propertyNames:
            pattern: ^[a-z][a-z0-9_]{0,63}$
          additionalProperties:
            anyOf:
              - type:
                  - string
                  - number
                  - boolean
              - $ref: "#/components/schemas/LocalizedText"
        status:
          $ref: "#/components/schemas/PublicationStatus"
      required:
        - style_number
        - name
      additionalProperties: false
      patternProperties:
        ^x-[a-z0-9]+-[a-z0-9_-]+$: true
    GtinInput:
      type: string
      description: "A GTIN as supplied: GTIN-8, UPC-A (12), EAN-13 or GTIN-14 digits."
      pattern: ^([0-9]{8}|[0-9]{12,14})$
      examples:
        - "2000010010011"
    Color:
      $schema: https://json-schema.org/draft/2020-12/schema
      $id: https://openbrandexchange.com/schemas/v0.1/Color.json
      type: object
      title: Color
      properties:
        code:
          type: string
          description: The brand's colour code, stable key (e.g. 'NVY', '001').
          minLength: 1
          maxLength: 64
        name:
          anyOf:
            - $ref: "#/components/schemas/LocalizedText"
            - type: "null"
        supplier_color:
          type:
            - string
            - "null"
          description: Colour name as used in the brand's own systems/labels.
          maxLength: 200
      required:
        - code
      additionalProperties: false
      patternProperties:
        ^x-[a-z0-9]+-[a-z0-9_-]+$: true
    VariantInput:
      $schema: https://json-schema.org/draft/2020-12/schema
      $id: https://openbrandexchange.com/schemas/v0.1/VariantInput.json
      type: object
      title: VariantInput
      description: "Ingest write model for a variant. Key: external_id (path); default convention = the GTIN, else `<style>-<color>-<size>`."
      properties:
        product_external_id:
          $ref: "#/components/schemas/ExternalId"
        gtin:
          anyOf:
            - $ref: "#/components/schemas/GtinInput"
              description: Check digit MUST be valid (invalid_gtin). Unique within the node (gtin_conflict).
            - type: "null"
        sku:
          type:
            - string
            - "null"
          maxLength: 200
        color:
          $ref: "#/components/schemas/Color"
        size:
          type:
            - string
            - "null"
          maxLength: 64
        size_system:
          type:
            - string
            - "null"
          maxLength: 16
        status:
          $ref: "#/components/schemas/PublicationStatus"
      required:
        - product_external_id
        - color
      additionalProperties: false
      patternProperties:
        ^x-[a-z0-9]+-[a-z0-9_-]+$: true
    Role:
      type: string
      enum:
        - packshot
        - model
        - detail
        - ghost
        - flatlay
        - scene
        - swatch
        - size_chart
        - other
      description: What the image shows. Stable key; labels are presentation.
    View:
      type: string
      enum:
        - front
        - back
        - side
        - left
        - right
        - top
        - bottom
        - tilted_left
        - tilted_right
        - inside
        - detail
        - other
      description: Viewing angle. Aligned with Fashion Cloud product_view plus 'inside'.
    Channel:
      type: string
      enum:
        - webshop
        - marketplace
        - social
        - print
      description: Sales/marketing channel a rights grant covers.
    Sha256:
      type: string
      description: Lowercase hex SHA-256 of the exact bytes.
      pattern: ^[a-f0-9]{64}$
    AssetInput:
      $schema: https://json-schema.org/draft/2020-12/schema
      $id: https://openbrandexchange.com/schemas/v0.1/AssetInput.json
      type: object
      title: AssetInput
      description: "Ingest write model for an asset. Key: external_id (path); filename-pattern convention = `<style>_<color>_<position>`."
      properties:
        product_external_id:
          $ref: "#/components/schemas/ExternalId"
        color_code:
          type:
            - string
            - "null"
          maxLength: 64
        variant_external_ids:
          type:
            - array
            - "null"
          items:
            $ref: "#/components/schemas/ExternalId"
        media_type:
          type: string
          enum:
            - image
        role:
          $ref: "#/components/schemas/Role"
        view:
          anyOf:
            - $ref: "#/components/schemas/View"
            - type: "null"
        position:
          type: integer
          minimum: 1
          maximum: 999
        filename:
          type:
            - string
            - "null"
          description: Download filename; default `<external_id>.<ext>`.
          maxLength: 255
        alt_text:
          anyOf:
            - $ref: "#/components/schemas/LocalizedText"
            - type: "null"
        rights:
          type:
            - object
            - "null"
          properties:
            license:
              type: string
              enum:
                - retailer-use
                - custom
            license_text:
              type:
                - string
                - "null"
              maxLength: 20000
            channels:
              type: array
              items:
                $ref: "#/components/schemas/Channel"
              uniqueItems: true
            territories:
              type:
                - array
                - "null"
              items:
                $ref: "#/components/schemas/CountryCode"
              uniqueItems: true
            embargo_until:
              anyOf:
                - $ref: "#/components/schemas/DateTime"
                - type: "null"
            expires_at:
              anyOf:
                - $ref: "#/components/schemas/DateTime"
                - type: "null"
            credit:
              type:
                - string
                - "null"
              maxLength: 500
            model_release:
              type:
                - boolean
                - "null"
          additionalProperties: false
          patternProperties:
            ^x-[a-z0-9]+-[a-z0-9_-]+$: true
          description: Partial Rights; omitted fields take the node's default rights (Settings). null = all defaults.
        provenance:
          type:
            - object
            - "null"
          properties:
            ai_generated:
              type: boolean
            ai_edited:
              type: boolean
            source:
              type: string
              enum:
                - photo
                - generated
                - composite
            c2pa:
              type: string
              enum:
                - embedded
                - sidecar
                - none
            c2pa_manifest_url:
              anyOf:
                - $ref: "#/components/schemas/Url"
                - type: "null"
            generator:
              type:
                - string
                - "null"
              maxLength: 200
          additionalProperties: false
          patternProperties:
            ^x-[a-z0-9]+-[a-z0-9_-]+$: true
          description: Omitted fields default to ai_generated=false, ai_edited=false, source=photo (generated when ai_generated), c2pa=none (nodes MAY detect embedded C2PA).
        files:
          type:
            - object
            - "null"
          description: Attach previously uploaded blobs atomically. Omit to keep current files (or upload via PUT .../files/{rendition}).
          properties:
            original:
              type: object
              description: Reference to an uploaded blob. width/height are required for client-generated renditions and for originals whose type the node cannot parse (e.g. TIFF).
              properties:
                sha256:
                  $ref: "#/components/schemas/Sha256"
                width:
                  type:
                    - integer
                    - "null"
                  minimum: 1
                height:
                  type:
                    - integer
                    - "null"
                  minimum: 1
              required:
                - sha256
              additionalProperties: false
              patternProperties:
                ^x-[a-z0-9]+-[a-z0-9_-]+$: true
            web:
              type: object
              description: Reference to an uploaded blob. width/height are required for client-generated renditions and for originals whose type the node cannot parse (e.g. TIFF).
              properties:
                sha256:
                  $ref: "#/components/schemas/Sha256"
                width:
                  type:
                    - integer
                    - "null"
                  minimum: 1
                height:
                  type:
                    - integer
                    - "null"
                  minimum: 1
              required:
                - sha256
              additionalProperties: false
              patternProperties:
                ^x-[a-z0-9]+-[a-z0-9_-]+$: true
            square:
              type: object
              description: Reference to an uploaded blob. width/height are required for client-generated renditions and for originals whose type the node cannot parse (e.g. TIFF).
              properties:
                sha256:
                  $ref: "#/components/schemas/Sha256"
                width:
                  type:
                    - integer
                    - "null"
                  minimum: 1
                height:
                  type:
                    - integer
                    - "null"
                  minimum: 1
              required:
                - sha256
              additionalProperties: false
              patternProperties:
                ^x-[a-z0-9]+-[a-z0-9_-]+$: true
            thumb:
              type: object
              description: Reference to an uploaded blob. width/height are required for client-generated renditions and for originals whose type the node cannot parse (e.g. TIFF).
              properties:
                sha256:
                  $ref: "#/components/schemas/Sha256"
                width:
                  type:
                    - integer
                    - "null"
                  minimum: 1
                height:
                  type:
                    - integer
                    - "null"
                  minimum: 1
              required:
                - sha256
              additionalProperties: false
              patternProperties:
                ^x-[a-z0-9]+-[a-z0-9_-]+$: true
          additionalProperties: false
          patternProperties:
            ^x-[a-z0-9]+-[a-z0-9_-]+$: true
        status:
          $ref: "#/components/schemas/PublicationStatus"
      required:
        - product_external_id
        - role
        - position
      additionalProperties: false
      patternProperties:
        ^x-[a-z0-9]+-[a-z0-9_-]+$: true
    Blob:
      $schema: https://json-schema.org/draft/2020-12/schema
      $id: https://openbrandexchange.com/schemas/v0.1/Blob.json
      type: object
      title: Blob
      properties:
        sha256:
          $ref: "#/components/schemas/Sha256"
        bytes:
          type: integer
          minimum: 1
        content_type:
          type: string
        width:
          type:
            - integer
            - "null"
          minimum: 1
        height:
          type:
            - integer
            - "null"
          minimum: 1
        created:
          type: boolean
          description: false when the node already had these bytes (dedupe).
      required:
        - sha256
        - bytes
        - content_type
        - created
      additionalProperties: false
      patternProperties:
        ^x-[a-z0-9]+-[a-z0-9_-]+$: true
    Upload:
      $schema: https://json-schema.org/draft/2020-12/schema
      $id: https://openbrandexchange.com/schemas/v0.1/Upload.json
      type: object
      title: Upload
      properties:
        id:
          $ref: "#/components/schemas/Id"
        sha256:
          $ref: "#/components/schemas/Sha256"
        bytes:
          type: integer
          minimum: 1
        content_type:
          type: string
        part_bytes:
          type: integer
          description: Every part except the last MUST be exactly this size (>= 5 MiB).
          minimum: 5242880
        parts_total:
          type: integer
          minimum: 1
        received_parts:
          type: array
          items:
            type: integer
            minimum: 1
          description: Part numbers already stored (resume by sending the others).
        status:
          type: string
          enum:
            - open
            - completed
            - aborted
            - expired
        expires_at:
          $ref: "#/components/schemas/DateTime"
      required:
        - id
        - sha256
        - bytes
        - content_type
        - part_bytes
        - parts_total
        - received_parts
        - status
        - expires_at
      additionalProperties: false
      patternProperties:
        ^x-[a-z0-9]+-[a-z0-9_-]+$: true
    IngestOperation:
      $schema: https://json-schema.org/draft/2020-12/schema
      $id: https://openbrandexchange.com/schemas/v0.1/IngestOperation.json
      type: object
      title: IngestOperation
      description: One operation of POST /obx/v1/ingest/batch (JSON array element or NDJSON line).
      properties:
        op:
          type: string
          enum:
            - upsert
            - patch
            - delete
            - publish
            - unpublish
          description: upsert = PUT (create or replace); patch = JSON merge patch; publish/unpublish = patch of status.
        kind:
          type: string
          enum:
            - brand
            - product
            - variant
            - asset
        external_id:
          $ref: "#/components/schemas/ExternalId"
        data:
          type: object
          description: "upsert: BrandInput|ProductInput|VariantInput|AssetInput by kind. patch: merge patch of that. Absent for delete/publish/unpublish."
        if_match:
          type:
            - string
            - "null"
          description: Optional ETag precondition.
      required:
        - op
        - kind
        - external_id
      additionalProperties: false
      patternProperties:
        ^x-[a-z0-9]+-[a-z0-9_-]+$: true
    ImportRowError:
      $schema: https://json-schema.org/draft/2020-12/schema
      $id: https://openbrandexchange.com/schemas/v0.1/ImportRowError.json
      type: object
      title: ImportRowError
      properties:
        file:
          type: string
          description: products.csv, assets.csv or the image file name.
          maxLength: 255
        row:
          type:
            - integer
            - "null"
          description: 1-based data row; null for header, file-level and image-file problems.
          minimum: 1
        column:
          type:
            - string
            - "null"
          maxLength: 100
        severity:
          type: string
          enum:
            - error
            - warning
          description: "error: row/file skipped. warning: imported with a correction."
        obx_error:
          $ref: "#/components/schemas/ErrorCode"
        detail:
          type: string
          maxLength: 2000
        value:
          type:
            - string
            - "null"
          maxLength: 500
      required:
        - file
        - row
        - severity
        - obx_error
        - detail
      additionalProperties: false
      patternProperties:
        ^x-[a-z0-9]+-[a-z0-9_-]+$: true
    ImportRun:
      $schema: https://json-schema.org/draft/2020-12/schema
      $id: https://openbrandexchange.com/schemas/v0.1/ImportRun.json
      type: object
      title: ImportRun
      properties:
        id:
          $ref: "#/components/schemas/Id"
        source_id:
          anyOf:
            - $ref: "#/components/schemas/Id"
              description: ImportSource for scheduled/pull runs; null for uploads.
            - type: "null"
        trigger:
          type: string
          enum:
            - upload
            - api
            - schedule
            - manual_run
            - cli
        status:
          type: string
          enum:
            - queued
            - running
            - succeeded
            - failed
          description: succeeded may still contain row errors (see counts.errors).
        mode:
          type: string
          enum:
            - upsert
            - mirror
        dry_run:
          type: boolean
        created_at:
          $ref: "#/components/schemas/DateTime"
        started_at:
          anyOf:
            - $ref: "#/components/schemas/DateTime"
            - type: "null"
        finished_at:
          anyOf:
            - $ref: "#/components/schemas/DateTime"
            - type: "null"
        files:
          type: array
          items:
            type: object
            properties:
              name:
                type: string
                maxLength: 1024
              bytes:
                type: integer
                minimum: 0
              sha256:
                anyOf:
                  - $ref: "#/components/schemas/Sha256"
                  - type: "null"
            required:
              - name
              - bytes
            additionalProperties: false
            patternProperties:
              ^x-[a-z0-9]+-[a-z0-9_-]+$: true
        counts:
          type: object
          properties:
            rows:
              type: integer
              minimum: 0
            files:
              type: integer
              minimum: 0
            created:
              type: integer
              minimum: 0
            updated:
              type: integer
              minimum: 0
            unchanged:
              type: integer
              minimum: 0
            unpublished:
              type: integer
              minimum: 0
            errors:
              type: integer
              minimum: 0
            warnings:
              type: integer
              minimum: 0
          required:
            - rows
            - files
            - created
            - updated
            - unchanged
            - unpublished
            - errors
            - warnings
          additionalProperties: false
          patternProperties:
            ^x-[a-z0-9]+-[a-z0-9_-]+$: true
        error:
          anyOf:
            - $ref: "#/components/schemas/Problem"
            - type: "null"
        errors_preview:
          type: array
          items:
            $ref: "#/components/schemas/ImportRowError"
          description: First <= 50 errors; page through the full list via the errors endpoint.
          maxItems: 50
      required:
        - id
        - source_id
        - trigger
        - status
        - mode
        - dry_run
        - created_at
        - counts
      additionalProperties: false
      patternProperties:
        ^x-[a-z0-9]+-[a-z0-9_-]+$: true
    IngestJob:
      $schema: https://json-schema.org/draft/2020-12/schema
      $id: https://openbrandexchange.com/schemas/v0.1/IngestJob.json
      type: object
      title: IngestJob
      properties:
        id:
          $ref: "#/components/schemas/Id"
        kind:
          type: string
          enum:
            - batch
            - import
        status:
          type: string
          enum:
            - queued
            - running
            - succeeded
            - failed
        created_at:
          $ref: "#/components/schemas/DateTime"
        finished_at:
          anyOf:
            - $ref: "#/components/schemas/DateTime"
            - type: "null"
        progress:
          type: object
          properties:
            done:
              type: integer
              minimum: 0
            total:
              type:
                - integer
                - "null"
              minimum: 0
          required:
            - done
            - total
          additionalProperties: false
          patternProperties:
            ^x-[a-z0-9]+-[a-z0-9_-]+$: true
        summary:
          type:
            - object
            - "null"
          properties:
            created:
              type: integer
              minimum: 0
            updated:
              type: integer
              minimum: 0
            unchanged:
              type: integer
              minimum: 0
            deleted:
              type: integer
              minimum: 0
            errors:
              type: integer
              minimum: 0
          required:
            - created
            - updated
            - unchanged
            - deleted
            - errors
          additionalProperties: false
          patternProperties:
            ^x-[a-z0-9]+-[a-z0-9_-]+$: true
        import_run:
          anyOf:
            - $ref: "#/components/schemas/ImportRun"
            - type: "null"
        results_url:
          $ref: "#/components/schemas/Url"
      required:
        - id
        - kind
        - status
        - created_at
        - progress
        - results_url
      additionalProperties: false
      patternProperties:
        ^x-[a-z0-9]+-[a-z0-9_-]+$: true
    Cursor:
      type: string
      description: Opaque pagination or feed cursor. Clients MUST NOT parse or construct it.
      minLength: 1
      maxLength: 4096
  responses:
    Record:
      description: The stored record.
      headers:
        ETag:
          schema:
            type: string
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/IngestRecord"
          examples:
            asset:
              summary: An image record with defaults applied
              value:
                kind: asset
                id: ast_1
                external_id: KO-1001_NVY_1
                status: published
                visibility: pending_files
                etag: '"c3d4"'
                created_at: 2026-09-28T10:00:00Z
                updated_at: 2026-09-28T10:00:00Z
                data:
                  product_external_id: KO-1001
                  color_code: NVY
                  role: packshot
                  view: front
                  position: 1
                  files:
                    original:
                      sha256: "2222222222222222222222222222222222222222222222222222222222222222"
                      width: 1200
                      height: 1500
                missing_files:
                  - web
                  - square
                  - thumb
    Result:
      description: Operation result (updated, unchanged or deleted).
      headers:
        ETag:
          schema:
            type: string
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/IngestResult"
          examples:
            unchanged:
              summary: Unchanged (no change entry is written)
              value:
                index: 0
                op: upsert
                kind: product
                external_id: KO-1001
                result: unchanged
                id: prd_1
                visibility: visible
                etag: '"a1b2"'
                error: null
                warnings: []
    ResultCreated:
      description: Created.
      headers:
        ETag:
          schema:
            type: string
        Location:
          schema:
            type: string
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/IngestResult"
          examples:
            created:
              summary: Created
              value:
                index: 0
                op: upsert
                kind: product
                external_id: KO-1001
                result: created
                id: prd_1
                visibility: visible
                etag: '"a1b2"'
                error: null
                warnings: []
    BadRequest:
      description: "`invalid_request` (malformed JSON/NDJSON/multipart) or `limit_exceeded`."
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/Problem"
    Unauthorized:
      description: "`unauthorized`, `invalid_token` or `access_revoked` (token revoked or expired)."
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/Problem"
    Forbidden:
      description: "`insufficient_scope` or `forbidden` (brand outside the token restriction)."
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/Problem"
    NotFound:
      description: "`not_found`."
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/Problem"
    Conflict:
      description: "`conflict`, `gtin_conflict`, `position_conflict` or `upload_incomplete`."
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/Problem"
    PreconditionFailed:
      description: "`precondition_failed` (If-Match did not match)."
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/Problem"
    PayloadTooLarge:
      description: "`payload_too_large`."
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/Problem"
    UnsupportedMediaType:
      description: "`unsupported_media_type`."
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/Problem"
    ValidationFailed:
      description: "`validation_failed`, `invalid_gtin`, `unknown_parent`, `blob_missing`, `checksum_mismatch` or `files_missing`."
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/Problem"
          examples:
            validation:
              summary: Field errors
              value:
                type: https://openbrandexchange.com/errors/validation_failed
                title: Validation failed
                status: 422
                detail: null
                instance: /obx/v1/ingest/variants/bad-gtin
                obx_error: validation_failed
                errors:
                  - pointer: /gtin
                    obx_error: invalid_gtin
                    detail: Check digit should be 8.
    TooManyRequests:
      description: "`rate_limited`."
      headers:
        Retry-After:
          schema:
            type: integer
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/Problem"
