openapi: 3.1.0
info:
  title: OBX Protocol (read side)
  version: 0.1.0
  summary: How retailers' software discovers a brand node, gets access and pulls product content.
  description: |
    **Normative.** The Open Brand Exchange (OBX) Protocol is the read side of a brand node:
    a retailer's software (shop, PIM, ERP, content hub) discovers the node, requests access,
    authenticates with OAuth2 client credentials and pulls brands, products, variants (with
    GTIN) and final images with role, view, order, rights and AI provenance.

    The prose specification in `docs/protocol.md` is authoritative where this file is silent.
    Key words MUST, SHOULD, MAY are to be interpreted as described in RFC 2119.

    Conventions for every endpoint:
    - JSON is UTF-8, field names are snake_case, timestamps are RFC 3339.
    - Clients MUST ignore unknown fields, unknown enum values and unknown change types.
      Extension fields are prefixed `x-<vendor>-` (for example `x-acme-crop`).
    - Errors are `application/problem+json` (RFC 9457) with an `obx_error` code, except the
      OAuth2 token endpoint which uses RFC 6749 error JSON.
    - Responses carry `OBX-Version: 0.1` and rate limit headers (`RateLimit-Limit`,
      `RateLimit-Remaining`, `RateLimit-Reset`); 429 carries `Retry-After`.
    - A retailer never sees more than it could see in the brand's portal: only published
      objects of brands in its grant.
  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}
    description: A brand node (also on the brand's custom domain).
    variables:
      host:
        default: brand.example.org
security:
  - oauth2:
      - catalog:read
tags:
  - name: Discovery
    description: Find out what a node offers.
  - name: Access
    description: Request access and obtain tokens.
  - name: Catalog
    description: Brands, products, variants and GTIN lookups.
  - name: Changes
    description: The change feed, the heart of synchronisation.
  - name: Media
    description: Assets and rendition downloads.
  - name: Exports
    description: Bulk exports as NDJSON manifest plus zip parts.
  - name: Webhooks
    description: Optional wake-up calls for consumers.
  - name: Usage
    description: Optional usage events reported back to the brand.
paths:
  /.well-known/obx:
    get:
      tags:
        - Discovery
      operationId: getDiscovery
      summary: Discovery document
      description: "Public. MUST be served with `Access-Control-Allow-Origin: *` and SHOULD be cacheable for 5 minutes. It is the only endpoint with CORS headers; all others send none (security.md section 4)."
      security: []
      responses:
        "200":
          description: The discovery document.
          headers:
            Cache-Control:
              schema:
                type: string
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DiscoveryDocument"
              examples:
                kestrel:
                  summary: Kestrel Outfitters
                  value:
                    obx_version: "0.1"
                    spec_version: 0.1.0
                    node:
                      software: obx-node-go
                      version: 0.1.0
                    publisher:
                      id: pub_kestrel
                      name: Kestrel Outfitters
                      logo_url: https://brands.kestrel.example/logo.png
                      accent_color: "#1F4E5F"
                      website: https://kestrel.example
                      contact_email: trade@kestrel.example
                      welcome_text:
                        en: Welcome, retail partners.
                        nl: Welkom, retailpartners.
                    api_base: https://brands.kestrel.example/obx/v1
                    portal_url: https://brands.kestrel.example/
                    auth:
                      token_endpoint: https://brands.kestrel.example/obx/v1/oauth/token
                      scopes_supported:
                        - catalog:read
                        - media:read
                        - media:original
                        - usage:write
                      token_endpoint_auth_methods_supported:
                        - client_secret_basic
                        - client_secret_post
                    access_requests_url: https://brands.kestrel.example/obx/v1/access-requests
                    ingest_url: https://brands.kestrel.example/obx/v1/ingest
                    capabilities:
                      - changes
                      - products
                      - by_gtin
                      - renditions
                      - exports
                      - webhooks
                      - usage
                      - access_requests
                      - ingest
                      - ingest.server_renditions
                      - portal
                    brands:
                      - id: brd_kestrel
                        name: Kestrel
                        logo_url: null
                      - id: brd_trail
                        name: Kestrel Trail
                        logo_url: null
                    languages:
                      - nl
                      - en
                    limits:
                      changes_page_max: 500
                      products_page_max: 100
                      gtin_lookup_max: 2000
                      tombstone_retention_days: 90
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "503":
          description: "`setup_required`: the node has not completed first-run setup."
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
  /obx/v1/access-requests:
    post:
      tags:
        - Access
      operationId: createAccessRequest
      summary: Request access (unauthenticated)
      description: |
        Creates a retailer access request that the brand approves or rejects in its admin.
        Unauthenticated and rate limited (RECOMMENDED: 5 per hour per IP). The response is the
        same whether or not the brand already knows the retailer (no enumeration). Nodes whose
        brand disabled requests answer 403 `forbidden`.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AccessRequestInput"
            examples:
              request:
                summary: A retailer asks for portal and API access
                value:
                  retailer:
                    name: Example Store
                    reference: C-1002
                    website: https://store.example
                    vat_number: null
                    country: NL
                  contact:
                    name: Sam Buyer
                    email: buyer@store.example
                    phone: null
                    language: nl
                  access:
                    - portal
                    - api
                  requested_brand_ids: null
                  message: We stock Kestrel since 2024.
      responses:
        "202":
          description: Request stored. Keep `request_token` to poll the status.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AccessRequest"
              examples:
                created:
                  summary: Just created (token shown once)
                  value:
                    id: acr_1
                    status: pending
                    created_at: 2026-09-28T10:00:00Z
                    decided_at: null
                    reason: null
                    request_token: obxr_4f1c2b9e8d7a6c5b4a39281706f5e4d3
                    status_url: https://brands.kestrel.example/obx/v1/access-requests/acr_1
        "403":
          $ref: "#/components/responses/Forbidden"
        "422":
          $ref: "#/components/responses/ValidationFailed"
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /obx/v1/access-requests/{id}:
    get:
      tags:
        - Access
      operationId: getAccessRequest
      summary: Poll an access request
      description: |
        Authenticated with the `request_token` from the 202 response as Bearer token.
        After approval with API access the response contains `credentials` with the
        `client_secret` exactly once (first read); later reads return `client_secret: null`
        and `claimed: true`. Poll at most once per minute.
      security:
        - requestToken: []
      parameters:
        - $ref: "#/components/parameters/IdPath"
      responses:
        "200":
          description: Current status.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AccessRequest"
              examples:
                created:
                  summary: Just created (token shown once)
                  value:
                    id: acr_1
                    status: pending
                    created_at: 2026-09-28T10:00:00Z
                    decided_at: null
                    reason: null
                    request_token: obxr_4f1c2b9e8d7a6c5b4a39281706f5e4d3
                    status_url: https://brands.kestrel.example/obx/v1/access-requests/acr_1
                approved:
                  summary: Approved; credentials on the first read
                  value:
                    id: acr_1
                    status: approved
                    created_at: 2026-09-28T10:00:00Z
                    decided_at: 2026-09-28T10:00:00Z
                    reason: null
                    status_url: https://brands.kestrel.example/obx/v1/access-requests/acr_1
                    portal:
                      invited: true
                      login_url: https://brands.kestrel.example/login
                    credentials:
                      client_id: obxc_8Jk2m
                      client_secret: obxs_Qm9yZ2V0LW1lLW5vdC10aGlzLWlzLWZha2U
                      token_endpoint: https://brands.kestrel.example/obx/v1/oauth/token
                      scopes:
                        - catalog:read
                        - media:read
                      claimed: true
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /obx/v1/oauth/token:
    post:
      tags:
        - Access
      operationId: createToken
      summary: OAuth2 token endpoint (client credentials)
      description: |
        RFC 6749 section 4.4. Nodes MUST support `client_secret_basic` and MAY support
        `client_secret_post`. `scope` is optional; when absent all scopes of the client are
        granted. Requesting a scope the client does not have yields `invalid_scope`.
        A revoked client or blocked retailer yields `invalid_client`. Access tokens SHOULD
        live 3600 s; there are no refresh tokens (request a new token).
      security:
        - clientBasic: []
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required:
                - grant_type
              properties:
                grant_type:
                  type: string
                  const: client_credentials
                scope:
                  type: string
                  description: Space-separated subset of catalog:read media:read media:original usage:write.
                client_id:
                  type: string
                  description: Only for client_secret_post.
                client_secret:
                  type: string
                  description: Only for client_secret_post.
      responses:
        "200":
          description: Access token.
          headers:
            Cache-Control:
              schema:
                type: string
                const: no-store
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TokenResponse"
              examples:
                token:
                  summary: Access token
                  value:
                    access_token: obxa_ZXhhbXBsZS1hY2Nlc3MtdG9rZW4
                    token_type: Bearer
                    expires_in: 3600
                    scope: catalog:read media:read
        "400":
          description: RFC 6749 error (invalid_request, unsupported_grant_type, invalid_scope).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OAuthError"
        "401":
          description: RFC 6749 invalid_client (unknown, wrong secret, revoked, retailer blocked).
          headers:
            WWW-Authenticate:
              schema:
                type: string
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OAuthError"
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /obx/v1/grant:
    get:
      tags:
        - Access
      operationId: getGrant
      summary: Who am I and what may I see
      responses:
        "200":
          description: The caller's grant.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Grant"
              examples:
                grant:
                  summary: A retailer restricted to one brand
                  value:
                    id: grt_3
                    retailer:
                      id: rtl_3
                      name: Example Store
                    brand_ids:
                      - brd_kestrel
                    scopes:
                      - catalog:read
                      - media:read
                      - usage:write
                    original_allowed: false
                    status: active
                    updated_at: 2026-09-28T10:00:00Z
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /obx/v1/changes:
    get:
      tags:
        - Changes
      operationId: listChanges
      summary: Change feed
      description: |
        Without `cursor` the node starts a **snapshot**: one `upsert` per currently visible
        object (brands, then products, then variants, then assets), `phase: snapshot`; then the
        feed switches to `phase: live` at the sequence number where the snapshot started.
        With a cursor it returns entries after it. Pages never mix phases; the last snapshot
        page has `has_more: true`.

        Entries are at-least-once: the same object may appear several times, `data` is always
        the CURRENT representation. Deletes are tombstones for anything no longer visible to
        the caller. Tombstones are kept at least 90 days; an older cursor yields 410
        `cursor_expired` (restart without cursor). A `grant` entry with `resync: true` means
        restart without a cursor. `next_cursor` is always returned; poll again later
        (RECOMMENDED: not more often than every 60 s when `has_more` is false, or on a
        `changes.available` webhook).
      parameters:
        - name: cursor
          in: query
          required: false
          schema:
            $ref: "#/components/schemas/Cursor"
        - name: limit
          in: query
          required: false
          description: Page size, 1-500 (default 100). Nodes MAY return fewer.
          schema:
            type: integer
            minimum: 1
            maximum: 500
            default: 100
      responses:
        "200":
          description: A page of changes.
          headers:
            RateLimit-Limit:
              $ref: "#/components/headers/RateLimit-Limit"
            RateLimit-Remaining:
              $ref: "#/components/headers/RateLimit-Remaining"
            RateLimit-Reset:
              $ref: "#/components/headers/RateLimit-Reset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ChangesPage"
              examples:
                live:
                  summary: A live page
                  value:
                    changes:
                      - seq: 1042
                        type: asset
                        op: delete
                        id: ast_7
                        external_id: KO-1001_NVY_2
                        brand_id: brd_kestrel
                        product_id: prd_1
                        updated_at: 2026-09-28T10:00:00Z
                      - seq: 1043
                        type: variant
                        op: upsert
                        id: var_9
                        external_id: "2000100101025"
                        brand_id: brd_kestrel
                        product_id: prd_1
                        updated_at: 2026-09-28T10:00:00Z
                        data:
                          id: var_9
                          external_id: "2000100101025"
                          product_id: prd_1
                          brand_id: brd_kestrel
                          gtin: "02000100101025"
                          gtin_original: "2000100101025"
                          sku: KO-1001-NVY-M
                          color:
                            code: NVY
                            name:
                              en: Navy
                              nl: Marineblauw
                            supplier_color: Deep Navy
                          size: M
                          size_system: INT
                          updated_at: 2026-09-28T10:00:00Z
                      - seq: 1044
                        type: grant
                        op: upsert
                        id: grt_3
                        external_id: null
                        brand_id: null
                        product_id: null
                        updated_at: 2026-09-28T10:00:00Z
                        resync: true
                        data:
                          id: grt_3
                          retailer:
                            id: rtl_3
                            name: Example Store
                          brand_ids: null
                          scopes:
                            - catalog:read
                            - media:read
                          original_allowed: false
                          status: active
                          updated_at: 2026-09-28T10:00:00Z
                    next_cursor: bGl2ZToxMDQ0
                    has_more: false
                    phase: live
        "400":
          $ref: "#/components/responses/CursorInvalid"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "410":
          $ref: "#/components/responses/CursorExpired"
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /obx/v1/brands:
    get:
      tags:
        - Catalog
      operationId: listBrands
      summary: Brands visible to the caller
      responses:
        "200":
          description: All visible brands (not paginated; a node has few brands).
          content:
            application/json:
              schema:
                type: object
                required:
                  - items
                properties:
                  items:
                    type: array
                    items:
                      $ref: "#/components/schemas/Brand"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /obx/v1/brands/{id}:
    get:
      tags:
        - Catalog
      operationId: getBrand
      summary: One brand
      parameters:
        - $ref: "#/components/parameters/IdPath"
      responses:
        "200":
          description: The brand.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Brand"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /obx/v1/products:
    get:
      tags:
        - Catalog
      operationId: listProducts
      summary: Products with variants and assets
      description: |
        Keyset-paginated, ordered by `id` ascending. For synchronisation use `/changes`;
        this endpoint is for browsing and targeted reads. Objects outside the caller's grant
        are never returned (unknown ids in `ids` are silently omitted).
      parameters:
        - name: ids
          in: query
          description: Comma-separated product ids (max 100). Other filters are ignored when set.
          schema:
            type: string
        - name: brand_id
          in: query
          schema:
            $ref: "#/components/schemas/Id"
        - name: updated_since
          in: query
          description: Products whose own data, a variant or an asset changed at or after this time.
          schema:
            $ref: "#/components/schemas/DateTime"
        - $ref: "#/components/parameters/Cursor"
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 25
      responses:
        "200":
          description: A page of products.
          content:
            application/json:
              schema:
                type: object
                required:
                  - items
                  - next_cursor
                properties:
                  items:
                    type: array
                    items:
                      $ref: "#/components/schemas/ProductDetail"
                  next_cursor:
                    description: null on the last page.
                    oneOf:
                      - $ref: "#/components/schemas/Cursor"
                      - type: "null"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /obx/v1/products/{id}:
    get:
      tags:
        - Catalog
      operationId: getProduct
      summary: One product with variants and assets
      parameters:
        - $ref: "#/components/parameters/IdPath"
      responses:
        "200":
          description: The product.
          headers:
            ETag:
              schema:
                type: string
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ProductDetail"
        "304":
          description: Not modified (If-None-Match).
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /obx/v1/products:by-gtin:
    get:
      tags:
        - Catalog
      operationId: lookupProductsByGtinQuery
      summary: Look up products by GTIN (short lists)
      description: Same as the POST form, for up to 100 codes in the query string.
      parameters:
        - name: gtin
          in: query
          required: true
          description: Comma-separated codes (max 100).
          schema:
            type: string
      responses:
        "200":
          description: Lookup result.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/GtinLookupResult"
        "400":
          $ref: "#/components/responses/LimitExceeded"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/TooManyRequests"
    post:
      tags:
        - Catalog
      operationId: lookupProductsByGtin
      summary: Look up products by GTIN (up to 2000)
      description: |
        Each input is trimmed and spaces removed; 8, 12, 13 or 14 digits are normalised to
        GTIN-14 by left-padding zeros and compared with `Variant.gtin`. Check digits are not
        validated here (a wrong check digit simply won't match). Other inputs are `invalid`.
        Duplicate inputs (after normalisation) are reported once. More than 2000 inputs:
        400 `limit_exceeded`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - gtins
              properties:
                gtins:
                  type: array
                  maxItems: 2000
                  items:
                    type: string
                    maxLength: 64
      responses:
        "200":
          description: Lookup result.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/GtinLookupResult"
        "400":
          $ref: "#/components/responses/LimitExceeded"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /obx/v1/assets/{id}:
    get:
      tags:
        - Media
      operationId: getAsset
      summary: One asset (fresh rendition URLs)
      description: Use this to refresh expired signed rendition URLs.
      security:
        - oauth2:
            - media:read
      parameters:
        - $ref: "#/components/parameters/IdPath"
      responses:
        "200":
          description: The asset.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Asset"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /obx/v1/assets/{id}/renditions/{name}:
    get:
      tags:
        - Media
      operationId: getRendition
      summary: Download a rendition
      description: |
        Authorised by Bearer token (scope media:read; media:original for `original`) OR by a
        valid signature in the URL as handed out in `Rendition.url`. Supports `Range`
        (206), `If-None-Match` (304). `ETag` is the quoted sha256; `OBX-Content-SHA256` is the
        sha256 of the full representation. `original` bytes are served exactly as uploaded
        (embedded C2PA is preserved). Nodes log each download for the brand's statistics.
      security:
        - oauth2:
            - media:read
        - signedUrl: []
      parameters:
        - $ref: "#/components/parameters/IdPath"
        - name: name
          in: path
          required: true
          schema:
            $ref: "#/components/schemas/RenditionName"
        - name: Range
          in: header
          required: false
          schema:
            type: string
        - name: If-None-Match
          in: header
          required: false
          schema:
            type: string
      responses:
        "200":
          description: The bytes.
          headers:
            ETag:
              $ref: "#/components/headers/ETag"
            OBX-Content-SHA256:
              $ref: "#/components/headers/OBX-Content-SHA256"
            Accept-Ranges:
              schema:
                type: string
                const: bytes
            Content-Disposition:
              description: '`inline; filename="<filename>"` (renditions get `_web`/`_square`/`_thumb` before a `.jpg` extension).'
              schema:
                type: string
            Cache-Control:
              description: private, max-age<=3600
              schema:
                type: string
          content:
            image/*:
              schema:
                type: string
                contentMediaType: application/octet-stream
        "206":
          description: Partial content.
          headers:
            Content-Range:
              schema:
                type: string
            ETag:
              $ref: "#/components/headers/ETag"
            OBX-Content-SHA256:
              $ref: "#/components/headers/OBX-Content-SHA256"
          content:
            image/*:
              schema:
                type: string
                contentMediaType: application/octet-stream
        "304":
          description: Not modified.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: "`insufficient_scope`, `original_not_allowed` or `forbidden` (e.g. expired signature)."
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "404":
          $ref: "#/components/responses/NotFound"
        "416":
          description: Range not satisfiable.
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /obx/v1/exports:
    post:
      tags:
        - Exports
      operationId: createExport
      summary: Start a bulk export
      description: |
        Capability `exports`. Asynchronous; poll GET /exports/{id} (RECOMMENDED every 10 s) or
        subscribe to `export.completed`. Nodes MAY limit concurrent exports per client (409
        `conflict` with the running export id in `detail`).
      security:
        - oauth2:
            - catalog:read
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ExportRequest"
            examples:
              web:
                summary: Web images of one brand
                value:
                  filter:
                    brand_ids:
                      - brd_kestrel
                    product_ids: null
                    gtins: null
                    updated_since: null
                  renditions:
                    - web
                  include_data: true
      responses:
        "202":
          description: Export queued.
          headers:
            Location:
              schema:
                type: string
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Export"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "409":
          $ref: "#/components/responses/Conflict"
        "422":
          $ref: "#/components/responses/ValidationFailed"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "501":
          $ref: "#/components/responses/NotImplemented"
  /obx/v1/exports/{id}:
    get:
      tags:
        - Exports
      operationId: getExport
      summary: Export status
      parameters:
        - $ref: "#/components/parameters/IdPath"
      responses:
        "200":
          description: Export status with manifest and part URLs once succeeded.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Export"
              examples:
                succeeded:
                  summary: Finished export
                  value:
                    id: exp_1
                    status: succeeded
                    created_at: 2026-09-28T10:00:00Z
                    finished_at: 2026-09-28T10:00:00Z
                    expires_at: 2026-09-29T10:00:00Z
                    progress:
                      done: 27
                      total: 27
                    manifest_url: https://brands.kestrel.example/obx/v1/exports/exp_1/manifest?exp=1790000000&sig=abc
                    parts:
                      - number: 1
                        url: https://brands.kestrel.example/obx/v1/exports/exp_1/parts/1?exp=1790000000&sig=def
                        bytes: 1048576
                        sha256: "0000000000000000000000000000000000000000000000000000000000000000"
                        files: 27
                    error: null
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /obx/v1/exports/{id}/manifest:
    get:
      tags:
        - Exports
      operationId: getExportManifest
      summary: Export manifest (NDJSON)
      security:
        - oauth2:
            - catalog:read
        - signedUrl: []
      parameters:
        - $ref: "#/components/parameters/IdPath"
      responses:
        "200":
          description: One ExportManifestLine per line; first line is the header.
          content:
            application/x-ndjson:
              schema:
                $ref: "#/components/schemas/ExportManifestLine"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "410":
          $ref: "#/components/responses/ExportExpired"
  /obx/v1/exports/{id}/parts/{number}:
    get:
      tags:
        - Exports
      operationId: getExportPart
      summary: Download a zip part
      security:
        - oauth2:
            - media:read
        - signedUrl: []
      parameters:
        - $ref: "#/components/parameters/IdPath"
        - name: number
          in: path
          required: true
          schema:
            type: integer
            minimum: 1
        - name: Range
          in: header
          required: false
          schema:
            type: string
      responses:
        "200":
          description: Zip file.
          headers:
            OBX-Content-SHA256:
              $ref: "#/components/headers/OBX-Content-SHA256"
            Accept-Ranges:
              schema:
                type: string
                const: bytes
          content:
            application/zip:
              schema:
                type: string
                contentMediaType: application/zip
        "206":
          description: Partial content.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "410":
          $ref: "#/components/responses/ExportExpired"
  /obx/v1/usage:
    post:
      tags:
        - Usage
      operationId: reportUsage
      summary: Report usage events
      description: Capability `usage`. Organisation-level events only; never personal data. Unknown asset ids are ignored.
      security:
        - oauth2:
            - usage:write
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/Usage"
            examples:
              usage:
                summary: Usage events
                value:
                  events:
                    - asset_id: ast_1
                      rendition: web
                      event: published
                      channel: webshop
                      at: 2026-09-28T10:00:00Z
                      count: 1
                    - asset_id: ast_2
                      rendition: null
                      event: synced
                      channel: null
                      at: 2026-09-28T10:00:00Z
      responses:
        "202":
          description: Accepted.
          content:
            application/json:
              schema:
                type: object
                required:
                  - accepted
                properties:
                  accepted:
                    type: integer
                    minimum: 0
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "422":
          $ref: "#/components/responses/ValidationFailed"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "501":
          $ref: "#/components/responses/NotImplemented"
  /obx/v1/webhooks:
    get:
      tags:
        - Webhooks
      operationId: listWebhooks
      summary: The caller's webhook subscriptions
      responses:
        "200":
          description: Subscriptions (secret is null).
          content:
            application/json:
              schema:
                type: object
                required:
                  - items
                properties:
                  items:
                    type: array
                    items:
                      $ref: "#/components/schemas/WebhookSubscription"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "501":
          $ref: "#/components/responses/NotImplemented"
    post:
      tags:
        - Webhooks
      operationId: createWebhook
      summary: Subscribe
      description: |
        Capability `webhooks`. Events for consumers: `changes.available`, `grant.updated`,
        `export.completed`. The URL MUST be https and MUST NOT resolve to a private, loopback or
        link-local address (422 `unsafe_url`). Max 5 subscriptions per client.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - url
                - events
              properties:
                url:
                  $ref: "#/components/schemas/Url"
                events:
                  type: array
                  minItems: 1
                  items:
                    type: string
                    enum:
                      - changes.available
                      - grant.updated
                      - export.completed
      responses:
        "201":
          description: Created; `secret` is returned only now.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WebhookSubscription"
              examples:
                created:
                  summary: Subscription
                  value:
                    id: whk_1
                    url: https://store.example/hooks/obx
                    events:
                      - changes.available
                      - grant.updated
                    secret: whsec_Tm90LWEtcmVhbC1zZWNyZXQtZXhhbXBsZQ
                    status: active
                    created_at: 2026-09-28T10:00:00Z
                    last_delivery_at: null
                    last_status: null
        "401":
          $ref: "#/components/responses/Unauthorized"
        "409":
          $ref: "#/components/responses/Conflict"
        "422":
          $ref: "#/components/responses/ValidationFailed"
        "501":
          $ref: "#/components/responses/NotImplemented"
  /obx/v1/webhooks/{id}:
    delete:
      tags:
        - Webhooks
      operationId: deleteWebhook
      summary: Unsubscribe
      parameters:
        - $ref: "#/components/parameters/IdPath"
      responses:
        "204":
          description: Deleted.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
  /obx/v1/webhooks/{id}/test:
    post:
      tags:
        - Webhooks
      operationId: testWebhook
      summary: Send a webhook.test event now
      parameters:
        - $ref: "#/components/parameters/IdPath"
      responses:
        "200":
          description: Delivery attempt result.
          content:
            application/json:
              schema:
                type: object
                required:
                  - delivered
                  - status
                properties:
                  delivered:
                    type: boolean
                  status:
                    description: HTTP status returned by the receiver, null on network error.
                    type:
                      - integer
                      - "null"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
webhooks:
  obxEvent:
    post:
      tags:
        - Webhooks
      operationId: receiveObxEvent
      summary: Event delivery to a consumer's webhook URL
      description: |
        Sent by the node to each active subscription. Headers: `OBX-Webhook-Id` (event id),
        `OBX-Webhook-Timestamp` (unix seconds), `OBX-Webhook-Signature: v1=<hex>` where
        hex = HMAC-SHA256(secret, `<timestamp>.<raw body>`). Receivers MUST verify in constant
        time and reject timestamps more than 300 s away. Respond 2xx within 10 s; otherwise the
        node retries with exponential backoff for up to 24 h. Webhooks are wake-up calls:
        always fetch state via /changes.
      parameters:
        - name: OBX-Webhook-Id
          in: header
          required: true
          schema:
            type: string
        - name: OBX-Webhook-Timestamp
          in: header
          required: true
          schema:
            type: string
            pattern: ^[0-9]+$
        - name: OBX-Webhook-Signature
          in: header
          required: true
          schema:
            type: string
            pattern: ^v1=[a-f0-9]{64}(, ?v1=[a-f0-9]{64})*$
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/WebhookEvent"
      responses:
        "200":
          description: Delivered (any 2xx).
        "400":
          description: Rejected (bad signature or stale timestamp); the node will retry.
components:
  securitySchemes:
    oauth2:
      type: oauth2
      description: Client credentials issued by the brand (admin or approved access request).
      flows:
        clientCredentials:
          tokenUrl: /obx/v1/oauth/token
          scopes:
            catalog:read: Read brands, products, variants, changes, lookups, exports metadata.
            media:read: Download web, square and thumb renditions and export zips.
            media:original: Download original files (only if the brand allows it for this retailer).
            usage:write: Report usage events.
    clientBasic:
      type: http
      scheme: basic
      description: client_id / client_secret (client_secret_basic).
    requestToken:
      type: http
      scheme: bearer
      description: The request_token returned when creating an access request.
    signedUrl:
      type: apiKey
      in: query
      name: sig
      description: Signature query parameters embedded in URLs handed out by the node (with an expiry). Opaque to clients.
  parameters:
    IdPath:
      name: id
      in: path
      required: true
      schema:
        $ref: "#/components/schemas/Id"
    Cursor:
      name: cursor
      in: query
      required: false
      schema:
        $ref: "#/components/schemas/Cursor"
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: Repeating a request with the same key within 24 h returns the original response.
      schema:
        type: string
        maxLength: 200
  headers:
    RateLimit-Limit:
      description: Requests allowed in the current window.
      schema:
        type: integer
    RateLimit-Remaining:
      description: Requests left in the current window.
      schema:
        type: integer
    RateLimit-Reset:
      description: Seconds until the window resets.
      schema:
        type: integer
    Retry-After:
      description: Seconds to wait.
      schema:
        type: integer
    ETag:
      description: Quoted lowercase hex sha256 of the representation.
      schema:
        type: string
    OBX-Content-SHA256:
      description: Lowercase hex sha256 of the complete representation (not of a range).
      schema:
        type: string
        pattern: ^[a-f0-9]{64}$
  schemas:
    OAuthError:
      type: object
      required:
        - error
      properties:
        error:
          type: string
          enum:
            - invalid_request
            - invalid_client
            - invalid_grant
            - unauthorized_client
            - unsupported_grant_type
            - invalid_scope
        error_description:
          type: string
    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
    Url:
      type: string
      description: Absolute http(s) URL.
      format: uri
      pattern: ^https?://
      maxLength: 2048
    HexColor:
      type: string
      description: "sRGB colour as #rrggbb."
      pattern: ^#[0-9a-fA-F]{6}$
      examples:
        - "#1F4E5F"
    Email:
      type: string
      description: E-mail address.
      format: email
      maxLength: 254
    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
    Scope:
      type: string
      enum:
        - catalog:read
        - media:read
        - media:original
        - usage:write
      description: OBX Protocol OAuth2 scope.
    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
    DiscoveryDocument:
      $schema: https://json-schema.org/draft/2020-12/schema
      $id: https://openbrandexchange.com/schemas/v0.1/DiscoveryDocument.json
      type: object
      title: DiscoveryDocument
      description: Served at GET /.well-known/obx on the node's host (also on custom domains).
      properties:
        obx_version:
          type: string
          description: Protocol major.minor implemented, e.g. '0.1'.
          pattern: ^[0-9]+\.[0-9]+$
        spec_version:
          type: string
          description: Exact spec version, e.g. '0.1.0'.
        node:
          type: object
          description: Implementation name/version, e.g. obx-node-go 0.1.0.
          properties:
            software:
              type: string
              maxLength: 100
            version:
              type: string
              maxLength: 100
          required:
            - software
            - version
          additionalProperties: false
          patternProperties:
            ^x-[a-z0-9]+-[a-z0-9_-]+$: true
        publisher:
          type: object
          description: The brand company operating this node.
          properties:
            id:
              $ref: "#/components/schemas/Id"
            name:
              type: string
              minLength: 1
              maxLength: 200
            logo_url:
              anyOf:
                - $ref: "#/components/schemas/Url"
                - type: "null"
            accent_color:
              anyOf:
                - $ref: "#/components/schemas/HexColor"
                - type: "null"
            website:
              anyOf:
                - $ref: "#/components/schemas/Url"
                - type: "null"
            contact_email:
              anyOf:
                - $ref: "#/components/schemas/Email"
                - type: "null"
            welcome_text:
              anyOf:
                - $ref: "#/components/schemas/LocalizedText"
                - type: "null"
          required:
            - id
            - name
          additionalProperties: false
          patternProperties:
            ^x-[a-z0-9]+-[a-z0-9_-]+$: true
        api_base:
          $ref: "#/components/schemas/Url"
          description: Base URL of the OBX Protocol, ending in /obx/v1 (no trailing slash).
        portal_url:
          anyOf:
            - $ref: "#/components/schemas/Url"
              description: Retailer portal URL (humans).
            - type: "null"
        auth:
          type: object
          properties:
            token_endpoint:
              $ref: "#/components/schemas/Url"
            scopes_supported:
              type: array
              items:
                $ref: "#/components/schemas/Scope"
            token_endpoint_auth_methods_supported:
              type: array
              items:
                type: string
                enum:
                  - client_secret_basic
                  - client_secret_post
              minItems: 1
          required:
            - token_endpoint
            - scopes_supported
            - token_endpoint_auth_methods_supported
          additionalProperties: false
          patternProperties:
            ^x-[a-z0-9]+-[a-z0-9_-]+$: true
        access_requests_url:
          anyOf:
            - $ref: "#/components/schemas/Url"
              description: POST endpoint for access requests; null when the brand does not accept requests.
            - type: "null"
        ingest_url:
          anyOf:
            - $ref: "#/components/schemas/Url"
              description: Base URL of the OBX Ingest API (/obx/v1/ingest) when enabled.
            - type: "null"
        capabilities:
          type: array
          items:
            type: string
            pattern: ^[a-z][a-z0-9_.-]*$
          description: "Supported optional features. Defined: changes, products, by_gtin, renditions, exports, webhooks, usage, access_requests, ingest, ingest.server_renditions, portal. Unknown values MUST be ignored."
          uniqueItems: true
        brands:
          type: array
          items:
            type: object
            properties:
              id:
                $ref: "#/components/schemas/Id"
              name:
                type: string
              logo_url:
                anyOf:
                  - $ref: "#/components/schemas/Url"
                  - type: "null"
            required:
              - id
              - name
            additionalProperties: false
            patternProperties:
              ^x-[a-z0-9]+-[a-z0-9_-]+$: true
          description: Published brands (public).
        languages:
          type: array
          items:
            $ref: "#/components/schemas/LanguageTag"
          description: Languages the content is offered in, first = default.
          minItems: 1
        limits:
          type: object
          properties:
            changes_page_max:
              type: integer
              minimum: 100
            products_page_max:
              type: integer
              minimum: 25
            gtin_lookup_max:
              type: integer
              minimum: 2000
            tombstone_retention_days:
              type: integer
              minimum: 90
          required:
            - changes_page_max
            - products_page_max
            - gtin_lookup_max
            - tombstone_retention_days
          additionalProperties: false
          patternProperties:
            ^x-[a-z0-9]+-[a-z0-9_-]+$: true
      required:
        - obx_version
        - spec_version
        - node
        - publisher
        - api_base
        - auth
        - capabilities
        - brands
        - languages
        - limits
      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
    CountryCode:
      type: string
      description: ISO 3166-1 alpha-2 country code.
      pattern: ^[A-Z]{2}$
      examples:
        - NL
    AccessRequestInput:
      $schema: https://json-schema.org/draft/2020-12/schema
      $id: https://openbrandexchange.com/schemas/v0.1/AccessRequestInput.json
      type: object
      title: AccessRequestInput
      properties:
        retailer:
          type: object
          properties:
            name:
              type: string
              description: Company / shop name.
              minLength: 1
              maxLength: 200
            reference:
              type:
                - string
                - "null"
              description: The retailer's customer number at the brand, if known.
              maxLength: 100
            website:
              anyOf:
                - $ref: "#/components/schemas/Url"
                - type: "null"
            vat_number:
              type:
                - string
                - "null"
              maxLength: 32
            country:
              anyOf:
                - $ref: "#/components/schemas/CountryCode"
                - type: "null"
          required:
            - name
          additionalProperties: false
          patternProperties:
            ^x-[a-z0-9]+-[a-z0-9_-]+$: true
        contact:
          type: object
          properties:
            name:
              type: string
              minLength: 1
              maxLength: 200
            email:
              $ref: "#/components/schemas/Email"
            phone:
              type:
                - string
                - "null"
              maxLength: 40
            language:
              anyOf:
                - $ref: "#/components/schemas/LanguageTag"
                - type: "null"
          required:
            - name
            - email
          additionalProperties: false
          patternProperties:
            ^x-[a-z0-9]+-[a-z0-9_-]+$: true
        access:
          type: array
          items:
            type: string
            enum:
              - portal
              - api
          description: 'What is requested: portal account and/or API client credentials. Default ["portal"].'
          minItems: 1
          uniqueItems: true
        requested_brand_ids:
          type:
            - array
            - "null"
          items:
            $ref: "#/components/schemas/Id"
          description: Brands of interest; null = all.
        message:
          type:
            - string
            - "null"
          maxLength: 2000
      required:
        - retailer
        - contact
      additionalProperties: false
      patternProperties:
        ^x-[a-z0-9]+-[a-z0-9_-]+$: true
    DateTime:
      type: string
      description: RFC 3339 timestamp, UTC recommended.
      format: date-time
      examples:
        - 2026-09-28T10:00:00Z
    AccessRequest:
      $schema: https://json-schema.org/draft/2020-12/schema
      $id: https://openbrandexchange.com/schemas/v0.1/AccessRequest.json
      type: object
      title: AccessRequest
      description: A retailer's request for access. Admin API adds retailer/contact details (see node-admin.yaml).
      properties:
        id:
          $ref: "#/components/schemas/Id"
        status:
          type: string
          enum:
            - pending
            - approved
            - rejected
        created_at:
          $ref: "#/components/schemas/DateTime"
        decided_at:
          anyOf:
            - $ref: "#/components/schemas/DateTime"
            - type: "null"
        reason:
          type:
            - string
            - "null"
          description: Rejection reason written by the brand (optional).
          maxLength: 2000
        request_token:
          type: string
          description: Returned ONLY in the 202 response of POST /access-requests. Bearer token for polling GET /access-requests/{id}. Store it; it is not shown again.
          minLength: 20
        status_url:
          $ref: "#/components/schemas/Url"
        portal:
          type:
            - object
            - "null"
          properties:
            invited:
              type: boolean
              description: An invitation e-mail was sent to the contact.
            login_url:
              $ref: "#/components/schemas/Url"
          required:
            - invited
            - login_url
          additionalProperties: false
          patternProperties:
            ^x-[a-z0-9]+-[a-z0-9_-]+$: true
        credentials:
          type:
            - object
            - "null"
          description: Present when API access was approved.
          properties:
            client_id:
              type: string
            client_secret:
              type:
                - string
                - "null"
              description: "Returned exactly once: on the first status read after approval. null afterwards."
            token_endpoint:
              $ref: "#/components/schemas/Url"
            scopes:
              type: array
              items:
                $ref: "#/components/schemas/Scope"
            claimed:
              type: boolean
              description: True once the secret has been returned.
          required:
            - client_id
            - client_secret
            - token_endpoint
            - scopes
            - claimed
          additionalProperties: false
          patternProperties:
            ^x-[a-z0-9]+-[a-z0-9_-]+$: true
      required:
        - id
        - status
        - created_at
      additionalProperties: false
      patternProperties:
        ^x-[a-z0-9]+-[a-z0-9_-]+$: true
    TokenResponse:
      $schema: https://json-schema.org/draft/2020-12/schema
      $id: https://openbrandexchange.com/schemas/v0.1/TokenResponse.json
      type: object
      title: TokenResponse
      properties:
        access_token:
          type: string
          minLength: 16
        token_type:
          const: Bearer
        expires_in:
          type: integer
          minimum: 60
          maximum: 86400
        scope:
          type: string
          description: Space-separated granted scopes.
      required:
        - access_token
        - token_type
        - expires_in
        - scope
    Grant:
      $schema: https://json-schema.org/draft/2020-12/schema
      $id: https://openbrandexchange.com/schemas/v0.1/Grant.json
      type: object
      title: Grant
      description: What a retailer is allowed to see. Returned by GET /obx/v1/grant and as 'data' of grant changes.
      properties:
        id:
          $ref: "#/components/schemas/Id"
        retailer:
          type: object
          properties:
            id:
              $ref: "#/components/schemas/Id"
            name:
              type: string
              maxLength: 200
          required:
            - id
            - name
          additionalProperties: false
          patternProperties:
            ^x-[a-z0-9]+-[a-z0-9_-]+$: true
        brand_ids:
          type:
            - array
            - "null"
          items:
            $ref: "#/components/schemas/Id"
          description: Brands this retailer may see; null = all published brands of the node.
        scopes:
          type: array
          items:
            $ref: "#/components/schemas/Scope"
          description: Scopes this retailer's API clients may request.
          uniqueItems: true
        original_allowed:
          type: boolean
          description: Whether original renditions are available to this retailer (brand setting AND grant).
        status:
          type: string
          enum:
            - active
            - revoked
        updated_at:
          $ref: "#/components/schemas/DateTime"
      required:
        - id
        - retailer
        - brand_ids
        - scopes
        - original_allowed
        - status
        - updated_at
      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
    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
    Brand:
      $schema: https://json-schema.org/draft/2020-12/schema
      $id: https://openbrandexchange.com/schemas/v0.1/Brand.json
      type: object
      title: Brand
      description: A brand (label) published by this node. One node (one brand company) may publish several brands.
      properties:
        id:
          $ref: "#/components/schemas/Id"
        external_id:
          $ref: "#/components/schemas/ExternalId"
        name:
          type: string
          description: Brand name as shown to retailers.
          minLength: 1
          maxLength: 200
        logo_url:
          anyOf:
            - $ref: "#/components/schemas/Url"
              description: Public logo URL (PNG/JPEG/WebP/SVG). Logos are public branding.
            - type: "null"
        website:
          anyOf:
            - $ref: "#/components/schemas/Url"
            - type: "null"
        description:
          anyOf:
            - $ref: "#/components/schemas/LocalizedText"
            - type: "null"
        updated_at:
          $ref: "#/components/schemas/DateTime"
      required:
        - id
        - external_id
        - name
        - updated_at
      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.
    Gtin14:
      type: string
      description: GTIN normalised to 14 digits (left-padded with zeros).
      pattern: ^[0-9]{14}$
      examples:
        - "02000010010011"
    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
    Variant:
      $schema: https://json-schema.org/draft/2020-12/schema
      $id: https://openbrandexchange.com/schemas/v0.1/Variant.json
      type: object
      title: Variant
      description: A sellable colour/size combination of a product.
      properties:
        id:
          $ref: "#/components/schemas/Id"
        external_id:
          $ref: "#/components/schemas/ExternalId"
        product_id:
          $ref: "#/components/schemas/Id"
        brand_id:
          $ref: "#/components/schemas/Id"
        gtin:
          anyOf:
            - $ref: "#/components/schemas/Gtin14"
              description: Normalised GTIN-14. The consumer's match key.
            - type: "null"
        gtin_original:
          anyOf:
            - $ref: "#/components/schemas/GtinInput"
              description: GTIN exactly as the brand supplied it (e.g. 13-digit EAN).
            - type: "null"
        sku:
          type:
            - string
            - "null"
          description: The brand's article/SKU number for this variant.
          maxLength: 200
        color:
          $ref: "#/components/schemas/Color"
        size:
          type:
            - string
            - "null"
          description: Size label, e.g. 'M', '42', '32/34', 'ONE SIZE'.
          maxLength: 64
        size_system:
          type:
            - string
            - "null"
          description: Optional size system, e.g. EU, UK, US, INT, FR, IT.
          maxLength: 16
        updated_at:
          $ref: "#/components/schemas/DateTime"
      required:
        - id
        - external_id
        - product_id
        - brand_id
        - color
        - updated_at
      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'.
    Sha256:
      type: string
      description: Lowercase hex SHA-256 of the exact bytes.
      pattern: ^[a-f0-9]{64}$
    Channel:
      type: string
      enum:
        - webshop
        - marketplace
        - social
        - print
      description: Sales/marketing channel a rights grant covers.
    Rights:
      $schema: https://json-schema.org/draft/2020-12/schema
      $id: https://openbrandexchange.com/schemas/v0.1/Rights.json
      type: object
      title: Rights
      properties:
        license:
          type: string
          enum:
            - retailer-use
            - custom
          description: "retailer-use: the standard OBX retailer licence (docs/protocol.md#rights): use to sell the brand's products in the listed channels and territories. custom: see license_text."
        license_text:
          type:
            - string
            - "null"
          description: Required when license is custom.
          maxLength: 20000
        channels:
          type: array
          items:
            $ref: "#/components/schemas/Channel"
          description: Channels the retailer may use the asset in.
          uniqueItems: true
        territories:
          type:
            - array
            - "null"
          items:
            $ref: "#/components/schemas/CountryCode"
          description: Allowed territories; null means worldwide.
          uniqueItems: true
        embargo_until:
          anyOf:
            - $ref: "#/components/schemas/DateTime"
              description: The asset is delivered ahead of time but MUST NOT be shown publicly before this moment.
            - type: "null"
        expires_at:
          anyOf:
            - $ref: "#/components/schemas/DateTime"
              description: After this moment the asset MUST no longer be used (it is also removed from the feed).
            - type: "null"
        credit:
          type:
            - string
            - "null"
          description: Required credit line, if any (e.g. photographer).
          maxLength: 500
        model_release:
          type:
            - boolean
            - "null"
          description: True when a model release covering the listed channels exists; null when unknown or not applicable.
      required:
        - license
        - channels
      additionalProperties: false
      patternProperties:
        ^x-[a-z0-9]+-[a-z0-9_-]+$: true
    Provenance:
      $schema: https://json-schema.org/draft/2020-12/schema
      $id: https://openbrandexchange.com/schemas/v0.1/Provenance.json
      type: object
      title: Provenance
      description: Origin of the image, required so AI provenance (EU AI Act art. 50) survives the brand -> retailer -> shop chain.
      properties:
        ai_generated:
          type: boolean
          description: True when the image is (mostly) generated by AI. A publisher that knows this MUST say so.
        ai_edited:
          type: boolean
          description: True when a real photo was materially edited with generative AI (e.g. background, model, garment fit).
        source:
          type: string
          enum:
            - photo
            - generated
            - composite
          description: "photo: camera original (possibly retouched); generated: synthesised; composite: photo combined with generated parts."
        c2pa:
          type: string
          enum:
            - embedded
            - sidecar
            - none
          description: "Where C2PA Content Credentials live. embedded: inside the original bytes (preserved: originals are served unmodified)."
        c2pa_manifest_url:
          anyOf:
            - $ref: "#/components/schemas/Url"
              description: Sidecar manifest URL (when c2pa is sidecar).
            - type: "null"
        generator:
          type:
            - string
            - "null"
          description: Optional free-text tool/model name.
          maxLength: 200
      required:
        - ai_generated
        - ai_edited
        - source
        - c2pa
      additionalProperties: false
      patternProperties:
        ^x-[a-z0-9]+-[a-z0-9_-]+$: true
    RenditionName:
      type: string
      enum:
        - original
        - web
        - square
        - thumb
      description: Rendition name.
    Rendition:
      $schema: https://json-schema.org/draft/2020-12/schema
      $id: https://openbrandexchange.com/schemas/v0.1/Rendition.json
      type: object
      title: Rendition
      description: One downloadable form of an asset. original = uploaded bytes, unmodified; web = longest side <= 2000 px JPEG; square = web centred on a white square canvas; thumb = longest side <= 600 px JPEG.
      properties:
        name:
          $ref: "#/components/schemas/RenditionName"
        url:
          $ref: "#/components/schemas/Url"
          description: Download URL. Either short-lived signed (usable without Authorization until url_expires_at) or requiring the Bearer token. Never a permanent public URL. Clients MUST treat it as opaque.
        url_expires_at:
          anyOf:
            - $ref: "#/components/schemas/DateTime"
            - type: "null"
        content_type:
          type: string
          description: Media type. 'original' keeps the uploaded type; web/square/thumb are image/jpeg.
          examples:
            - image/jpeg
        width:
          type: integer
          minimum: 1
        height:
          type: integer
          minimum: 1
        bytes:
          type: integer
          minimum: 1
        sha256:
          $ref: "#/components/schemas/Sha256"
      required:
        - name
        - url
        - content_type
        - width
        - height
        - bytes
        - sha256
      additionalProperties: false
      patternProperties:
        ^x-[a-z0-9]+-[a-z0-9_-]+$: true
    Asset:
      $schema: https://json-schema.org/draft/2020-12/schema
      $id: https://openbrandexchange.com/schemas/v0.1/Asset.json
      type: object
      title: Asset
      description: A final, published product image with order, rights and provenance.
      properties:
        id:
          $ref: "#/components/schemas/Id"
        external_id:
          $ref: "#/components/schemas/ExternalId"
        product_id:
          $ref: "#/components/schemas/Id"
        brand_id:
          $ref: "#/components/schemas/Id"
        media_type:
          type: string
          enum:
            - image
          description: Kind of media. v0.1 only defines image; consumers MUST ignore unknown values.
        color_code:
          type:
            - string
            - "null"
          description: The colour this asset belongs to (Variant.color.code); null = all colours of the product.
          maxLength: 64
        variant_ids:
          type:
            - array
            - "null"
          items:
            $ref: "#/components/schemas/Id"
          description: "Optional: restrict the asset to specific variants (rare)."
        role:
          $ref: "#/components/schemas/Role"
        view:
          anyOf:
            - $ref: "#/components/schemas/View"
            - type: "null"
        position:
          type: integer
          description: Order within (product, color_code), 1-based. Unique among visible assets of that product+colour.
          minimum: 1
          maximum: 999
        filename:
          type: string
          description: Suggested download filename for the original, e.g. KO-1001_NVY_1.jpg.
          minLength: 1
          maxLength: 255
        alt_text:
          anyOf:
            - $ref: "#/components/schemas/LocalizedText"
            - type: "null"
        content_type:
          type: string
          description: Media type of the original.
          examples:
            - image/jpeg
        width:
          type: integer
          description: Original width in px.
          minimum: 1
        height:
          type: integer
          description: Original height in px.
          minimum: 1
        bytes:
          type: integer
          description: Original size in bytes.
          minimum: 1
        sha256:
          $ref: "#/components/schemas/Sha256"
          description: SHA-256 of the original bytes (always present, even when the original rendition is not offered to this consumer).
        rights:
          $ref: "#/components/schemas/Rights"
        provenance:
          $ref: "#/components/schemas/Provenance"
        renditions:
          type: object
          description: "Renditions by name. 'original' is present only when the brand allows original downloads AND the caller has media:original (portal: the retailer is allowed originals)."
          properties:
            original:
              $ref: "#/components/schemas/Rendition"
            web:
              $ref: "#/components/schemas/Rendition"
            square:
              $ref: "#/components/schemas/Rendition"
            thumb:
              $ref: "#/components/schemas/Rendition"
          required:
            - web
            - square
            - thumb
          additionalProperties: false
          patternProperties:
            ^x-[a-z0-9]+-[a-z0-9_-]+$: true
        updated_at:
          $ref: "#/components/schemas/DateTime"
      required:
        - id
        - external_id
        - product_id
        - brand_id
        - media_type
        - color_code
        - role
        - view
        - position
        - filename
        - content_type
        - width
        - height
        - bytes
        - sha256
        - rights
        - provenance
        - renditions
        - updated_at
      additionalProperties: false
      patternProperties:
        ^x-[a-z0-9]+-[a-z0-9_-]+$: true
    Product:
      $schema: https://json-schema.org/draft/2020-12/schema
      $id: https://openbrandexchange.com/schemas/v0.1/Product.json
      type: object
      title: Product
      description: A style/model. In /changes entries 'data' holds the product without 'variants' and 'assets'; product endpoints return ProductDetail (with both arrays).
      properties:
        id:
          $ref: "#/components/schemas/Id"
        external_id:
          $ref: "#/components/schemas/ExternalId"
        brand_id:
          $ref: "#/components/schemas/Id"
        style_number:
          type: string
          description: The brand's style/model number.
          minLength: 1
          maxLength: 200
        name:
          $ref: "#/components/schemas/LocalizedText"
        description:
          anyOf:
            - $ref: "#/components/schemas/LocalizedText"
            - type: "null"
        item_type:
          type:
            - string
            - "null"
          description: Open vocabulary; recommended keys in docs/protocol.md#item-types (e.g. t_shirt, sweater, jacket, jeans, bag).
          pattern: ^[a-z][a-z0-9_]{0,63}$
        gpc_brick:
          type:
            - string
            - "null"
          description: Optional GS1 GPC brick code.
          pattern: ^[0-9]{8}$
        target_group:
          type:
            - string
            - "null"
          description: "Open vocabulary; recommended: women, men, unisex, girls, boys, kids, baby."
          pattern: ^[a-z][a-z0-9_]{0,63}$
        season:
          type:
            - string
            - "null"
          description: Season label as used by the brand, e.g. SS26, AW26, NOS.
          maxLength: 64
        collection:
          type:
            - string
            - "null"
          description: Collection name.
          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"
              description: Link to the product's Digital Product Passport, when one exists.
            - type: "null"
        attributes:
          type:
            - object
            - "null"
          description: Additional brand attributes. Keys lower_snake_case; values string, number, boolean or LocalizedText.
          propertyNames:
            pattern: ^[a-z][a-z0-9_]{0,63}$
          additionalProperties:
            anyOf:
              - type:
                  - string
                  - number
                  - boolean
              - $ref: "#/components/schemas/LocalizedText"
        created_at:
          $ref: "#/components/schemas/DateTime"
        updated_at:
          $ref: "#/components/schemas/DateTime"
          description: Changes whenever the protocol representation of this product (own fields only) changes.
        variants:
          type: array
          items:
            $ref: "#/components/schemas/Variant"
          description: Present in product responses (GET /products...), absent in /changes data. Only published variants.
        assets:
          type: array
          items:
            $ref: "#/components/schemas/Asset"
          description: Present in product responses (GET /products...), absent in /changes data. Only visible assets, ordered by color_code then position.
      required:
        - id
        - external_id
        - brand_id
        - style_number
        - name
        - updated_at
      additionalProperties: false
      patternProperties:
        ^x-[a-z0-9]+-[a-z0-9_-]+$: true
    Change:
      $schema: https://json-schema.org/draft/2020-12/schema
      $id: https://openbrandexchange.com/schemas/v0.1/Change.json
      type: object
      title: Change
      description: One entry of the change feed.
      properties:
        seq:
          type: integer
          description: Node-wide monotonically increasing sequence number of this entry (snapshot entries carry the object's latest seq).
          minimum: 1
        type:
          type: string
          enum:
            - brand
            - product
            - variant
            - asset
            - grant
          description: Object type. Consumers MUST ignore entries with unknown types.
        op:
          type: string
          enum:
            - upsert
            - delete
          description: "upsert: the object is visible to you with this current state. delete: tombstone, the object is no longer visible to you (deleted, unpublished, parent hidden, outside grant, rights expired)."
        id:
          $ref: "#/components/schemas/Id"
        external_id:
          anyOf:
            - $ref: "#/components/schemas/ExternalId"
            - type: "null"
        brand_id:
          anyOf:
            - $ref: "#/components/schemas/Id"
            - type: "null"
        product_id:
          anyOf:
            - $ref: "#/components/schemas/Id"
              description: Parent product for variant/asset entries.
            - type: "null"
        updated_at:
          $ref: "#/components/schemas/DateTime"
          description: "upsert: the object's updated_at. delete: when it stopped being visible."
        data:
          description: "Present on upsert (never on delete): the CURRENT representation (Brand, Product without variants/assets, Variant, Asset or Grant according to type)."
          anyOf:
            - $ref: "#/components/schemas/Brand"
            - $ref: "#/components/schemas/Product"
            - $ref: "#/components/schemas/Variant"
            - $ref: "#/components/schemas/Asset"
            - $ref: "#/components/schemas/Grant"
        resync:
          type: boolean
          description: "Only on type=grant: true when your visible set changed; restart /changes without a cursor (full snapshot) and sweep."
      required:
        - seq
        - type
        - op
        - id
        - updated_at
      additionalProperties: false
      patternProperties:
        ^x-[a-z0-9]+-[a-z0-9_-]+$: true
    ChangesPage:
      $schema: https://json-schema.org/draft/2020-12/schema
      $id: https://openbrandexchange.com/schemas/v0.1/ChangesPage.json
      type: object
      title: ChangesPage
      properties:
        changes:
          type: array
          items:
            $ref: "#/components/schemas/Change"
        next_cursor:
          $ref: "#/components/schemas/Cursor"
          description: Always present. Pass it on the next call (immediately when has_more, later otherwise).
        has_more:
          type: boolean
          description: True when more entries are available right now.
        phase:
          type: string
          enum:
            - snapshot
            - live
          description: "snapshot: replaying current state after a cursor-less start; live: incremental changes. The first page with phase=live after a snapshot means the snapshot is complete."
      required:
        - changes
        - next_cursor
        - has_more
        - phase
      additionalProperties: false
      patternProperties:
        ^x-[a-z0-9]+-[a-z0-9_-]+$: true
    ProductDetail:
      $schema: https://json-schema.org/draft/2020-12/schema
      $id: https://openbrandexchange.com/schemas/v0.1/ProductDetail.json
      title: ProductDetail
      description: A Product with its published variants and visible assets embedded. Returned by GET /products, /products/{id} and by-GTIN lookups.
      type: object
      $ref: "#/components/schemas/Product"
      required:
        - variants
        - assets
      properties:
        variants:
          type: array
          items:
            $ref: "#/components/schemas/Variant"
        assets:
          type: array
          items:
            $ref: "#/components/schemas/Asset"
    GtinLookupResult:
      $schema: https://json-schema.org/draft/2020-12/schema
      $id: https://openbrandexchange.com/schemas/v0.1/GtinLookupResult.json
      type: object
      title: GtinLookupResult
      properties:
        results:
          type: array
          items:
            type: object
            properties:
              input:
                type: string
                description: The code as submitted (trimmed).
                maxLength: 64
              gtin:
                anyOf:
                  - $ref: "#/components/schemas/Gtin14"
                  - type: "null"
              status:
                type: string
                enum:
                  - matched
                  - unmatched
                  - invalid
                description: "invalid: not 8, 12, 13 or 14 digits after removing spaces."
              product_id:
                anyOf:
                  - $ref: "#/components/schemas/Id"
                  - type: "null"
              variant_id:
                anyOf:
                  - $ref: "#/components/schemas/Id"
                  - type: "null"
            required:
              - input
              - gtin
              - status
              - product_id
              - variant_id
            additionalProperties: false
            patternProperties:
              ^x-[a-z0-9]+-[a-z0-9_-]+$: true
          description: One result per distinct normalised input, in input order.
        products:
          type: array
          items:
            $ref: "#/components/schemas/ProductDetail"
          description: Each matched product once.
      required:
        - results
        - products
      additionalProperties: false
      patternProperties:
        ^x-[a-z0-9]+-[a-z0-9_-]+$: true
    ExportRequest:
      $schema: https://json-schema.org/draft/2020-12/schema
      $id: https://openbrandexchange.com/schemas/v0.1/ExportRequest.json
      type: object
      title: ExportRequest
      properties:
        filter:
          type: object
          description: All given filters must match (AND). Empty = everything visible.
          properties:
            brand_ids:
              type:
                - array
                - "null"
              items:
                $ref: "#/components/schemas/Id"
            product_ids:
              type:
                - array
                - "null"
              items:
                $ref: "#/components/schemas/Id"
              maxItems: 10000
            gtins:
              type:
                - array
                - "null"
              items:
                $ref: "#/components/schemas/GtinInput"
              maxItems: 10000
            updated_since:
              anyOf:
                - $ref: "#/components/schemas/DateTime"
                - type: "null"
          additionalProperties: false
          patternProperties:
            ^x-[a-z0-9]+-[a-z0-9_-]+$: true
        renditions:
          type: array
          items:
            $ref: "#/components/schemas/RenditionName"
          description: Renditions to include as files; [] = data only. 'original' requires media:original.
          uniqueItems: true
        include_data:
          type: boolean
          description: Include product/variant/asset lines in the manifest (default true).
          default: true
      required:
        - renditions
      additionalProperties: false
      patternProperties:
        ^x-[a-z0-9]+-[a-z0-9_-]+$: true
    Export:
      $schema: https://json-schema.org/draft/2020-12/schema
      $id: https://openbrandexchange.com/schemas/v0.1/Export.json
      type: object
      title: Export
      properties:
        id:
          $ref: "#/components/schemas/Id"
        status:
          type: string
          enum:
            - queued
            - running
            - succeeded
            - failed
            - expired
        created_at:
          $ref: "#/components/schemas/DateTime"
        finished_at:
          anyOf:
            - $ref: "#/components/schemas/DateTime"
            - type: "null"
        expires_at:
          anyOf:
            - $ref: "#/components/schemas/DateTime"
              description: Files are deleted after this (>= 24 h after finishing).
            - 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
        manifest_url:
          anyOf:
            - $ref: "#/components/schemas/Url"
              description: NDJSON manifest (ExportManifestLine per line); requires Bearer or is signed.
            - type: "null"
        parts:
          type: array
          items:
            type: object
            properties:
              number:
                type: integer
                minimum: 1
              url:
                $ref: "#/components/schemas/Url"
              bytes:
                type: integer
                minimum: 1
              sha256:
                $ref: "#/components/schemas/Sha256"
              files:
                type: integer
                minimum: 0
            required:
              - number
              - url
              - bytes
              - sha256
              - files
            additionalProperties: false
            patternProperties:
              ^x-[a-z0-9]+-[a-z0-9_-]+$: true
          description: Independent zip files (not a split archive), each <= 2 GiB.
        error:
          anyOf:
            - $ref: "#/components/schemas/Problem"
            - type: "null"
      required:
        - id
        - status
        - created_at
        - progress
        - parts
      additionalProperties: false
      patternProperties:
        ^x-[a-z0-9]+-[a-z0-9_-]+$: true
    ExportManifestLine:
      $schema: https://json-schema.org/draft/2020-12/schema
      $id: https://openbrandexchange.com/schemas/v0.1/ExportManifestLine.json
      title: ExportManifestLine
      description: One line of an export manifest (NDJSON). First line is 'header'. Consumers MUST ignore unknown line types.
      oneOf:
        - type: object
          properties:
            type:
              const: header
            export_id:
              $ref: "#/components/schemas/Id"
            created_at:
              $ref: "#/components/schemas/DateTime"
            obx_version:
              type: string
            changes_cursor:
              $ref: "#/components/schemas/Cursor"
              description: "Live /changes cursor positioned at the export's snapshot moment: continue incrementally from here."
            counts:
              type: object
              properties:
                products:
                  type: integer
                  minimum: 0
                variants:
                  type: integer
                  minimum: 0
                assets:
                  type: integer
                  minimum: 0
                files:
                  type: integer
                  minimum: 0
              required:
                - products
                - variants
                - assets
                - files
              additionalProperties: false
              patternProperties:
                ^x-[a-z0-9]+-[a-z0-9_-]+$: true
          required:
            - type
            - export_id
            - created_at
            - obx_version
            - changes_cursor
            - counts
          additionalProperties: false
          patternProperties:
            ^x-[a-z0-9]+-[a-z0-9_-]+$: true
        - type: object
          properties:
            type:
              const: brand
            data:
              $ref: "#/components/schemas/Brand"
          required:
            - type
            - data
          additionalProperties: false
          patternProperties:
            ^x-[a-z0-9]+-[a-z0-9_-]+$: true
        - type: object
          properties:
            type:
              const: product
            data:
              $ref: "#/components/schemas/Product"
          required:
            - type
            - data
          additionalProperties: false
          patternProperties:
            ^x-[a-z0-9]+-[a-z0-9_-]+$: true
        - type: object
          properties:
            type:
              const: variant
            data:
              $ref: "#/components/schemas/Variant"
          required:
            - type
            - data
          additionalProperties: false
          patternProperties:
            ^x-[a-z0-9]+-[a-z0-9_-]+$: true
        - type: object
          properties:
            type:
              const: asset
            data:
              $ref: "#/components/schemas/Asset"
          required:
            - type
            - data
          additionalProperties: false
          patternProperties:
            ^x-[a-z0-9]+-[a-z0-9_-]+$: true
        - type: object
          properties:
            type:
              const: file
            asset_id:
              $ref: "#/components/schemas/Id"
            rendition:
              $ref: "#/components/schemas/RenditionName"
            part:
              type: integer
              minimum: 1
            path:
              type: string
              description: "Path inside the zip part: `<style_number>/<rendition>/<filename>`."
              maxLength: 1024
            bytes:
              type: integer
              minimum: 1
            sha256:
              $ref: "#/components/schemas/Sha256"
          required:
            - type
            - asset_id
            - rendition
            - part
            - path
            - bytes
            - sha256
          additionalProperties: false
          patternProperties:
            ^x-[a-z0-9]+-[a-z0-9_-]+$: true
    UsageEvent:
      $schema: https://json-schema.org/draft/2020-12/schema
      $id: https://openbrandexchange.com/schemas/v0.1/UsageEvent.json
      type: object
      title: UsageEvent
      properties:
        asset_id:
          $ref: "#/components/schemas/Id"
        rendition:
          anyOf:
            - $ref: "#/components/schemas/RenditionName"
            - type: "null"
        event:
          type: string
          enum:
            - synced
            - published
            - downloaded
            - removed
          description: "synced: stored in the retailer's system; published: live in a channel; downloaded: fetched by a person; removed: taken offline."
        channel:
          anyOf:
            - $ref: "#/components/schemas/Channel"
            - type: "null"
        at:
          $ref: "#/components/schemas/DateTime"
        count:
          type: integer
          minimum: 1
          default: 1
      required:
        - asset_id
        - event
        - at
      additionalProperties: false
      patternProperties:
        ^x-[a-z0-9]+-[a-z0-9_-]+$: true
    Usage:
      $schema: https://json-schema.org/draft/2020-12/schema
      $id: https://openbrandexchange.com/schemas/v0.1/Usage.json
      type: object
      title: Usage
      description: "Body of POST /obx/v1/usage. Organisation-level only: never include personal data."
      properties:
        events:
          type: array
          items:
            $ref: "#/components/schemas/UsageEvent"
          minItems: 1
          maxItems: 1000
      required:
        - events
      additionalProperties: false
      patternProperties:
        ^x-[a-z0-9]+-[a-z0-9_-]+$: true
    WebhookSubscription:
      $schema: https://json-schema.org/draft/2020-12/schema
      $id: https://openbrandexchange.com/schemas/v0.1/WebhookSubscription.json
      type: object
      title: WebhookSubscription
      properties:
        id:
          $ref: "#/components/schemas/Id"
        url:
          $ref: "#/components/schemas/Url"
          description: HTTPS endpoint receiving events (http only for localhost in dev mode).
        events:
          type: array
          items:
            type: string
            pattern: ^[a-z_]+\.[a-z_]+$
          description: Event types; ['*'] not allowed, list explicitly.
          minItems: 1
          uniqueItems: true
        secret:
          type:
            - string
            - "null"
          description: Signing secret, 'whsec_' + random. Returned only on creation.
        status:
          type: string
          enum:
            - active
            - disabled
          description: disabled after 3 days of failed deliveries (re-enable by recreating).
        created_at:
          $ref: "#/components/schemas/DateTime"
        last_delivery_at:
          anyOf:
            - $ref: "#/components/schemas/DateTime"
            - type: "null"
        last_status:
          type:
            - integer
            - "null"
      required:
        - id
        - url
        - events
        - status
        - created_at
      additionalProperties: false
      patternProperties:
        ^x-[a-z0-9]+-[a-z0-9_-]+$: true
    WebhookEvent:
      $schema: https://json-schema.org/draft/2020-12/schema
      $id: https://openbrandexchange.com/schemas/v0.1/WebhookEvent.json
      type: object
      title: WebhookEvent
      description: "Webhook payload. Signed: OBX-Webhook-Signature: v1=<hex HMAC-SHA256(secret, timestamp + '.' + raw_body)>."
      properties:
        id:
          type: string
          description: Unique event id (use for de-duplication).
          minLength: 1
          maxLength: 128
        type:
          type: string
          description: "Protocol: changes.available, grant.updated, export.completed. Brand admin: access_request.created, import_run.completed, ingest_job.completed. Both: webhook.test. Receivers MUST ignore unknown types."
          pattern: ^[a-z_]+\.[a-z_]+$
        created_at:
          $ref: "#/components/schemas/DateTime"
        data:
          type: object
          description: "changes.available: {latest_seq}. grant.updated: {grant: Grant}. export.completed: {export_id, status}. access_request.created: {access_request_id, retailer_name}. import_run.completed: {import_run_id, status, counts}. ingest_job.completed: {job_id, status}. webhook.test: {}. Webhooks are wake-up calls: always fetch state through the API."
      required:
        - id
        - type
        - created_at
        - data
      additionalProperties: false
      patternProperties:
        ^x-[a-z0-9]+-[a-z0-9_-]+$: true
  responses:
    BadRequest:
      description: "`invalid_request`."
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/Problem"
    CursorInvalid:
      description: "`cursor_invalid` (malformed or from another node) or `invalid_request`."
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/Problem"
    LimitExceeded:
      description: "`limit_exceeded` or `invalid_request`."
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/Problem"
    Unauthorized:
      description: "`unauthorized` (no credentials), `invalid_token` (expired/unknown) or `access_revoked` (client revoked or retailer blocked: stop syncing, keep content per your own policy)."
      headers:
        WWW-Authenticate:
          schema:
            type: string
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/Problem"
    Forbidden:
      description: "`insufficient_scope`, `forbidden` or `access_revoked`."
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/Problem"
    NotFound:
      description: "`not_found`. Also used for objects outside the caller's grant (never 403, no existence leak)."
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/Problem"
    Conflict:
      description: "`conflict`."
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/Problem"
    CursorExpired:
      description: "`cursor_expired`: restart /changes without a cursor."
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/Problem"
          examples:
            expired:
              summary: Cursor too old
              value:
                type: https://openbrandexchange.com/errors/cursor_expired
                title: Cursor expired
                status: 410
                detail: The cursor is older than the tombstone retention; restart without a cursor.
                instance: null
                obx_error: cursor_expired
                retry_after: null
    ExportExpired:
      description: "`export_expired`."
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/Problem"
    ValidationFailed:
      description: "`validation_failed` with field errors, or `unsafe_url`."
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/Problem"
    TooManyRequests:
      description: "`rate_limited`."
      headers:
        Retry-After:
          $ref: "#/components/headers/Retry-After"
        RateLimit-Limit:
          $ref: "#/components/headers/RateLimit-Limit"
        RateLimit-Remaining:
          $ref: "#/components/headers/RateLimit-Remaining"
        RateLimit-Reset:
          $ref: "#/components/headers/RateLimit-Reset"
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/Problem"
    NotImplemented:
      description: "`not_implemented`: optional capability not offered by this node."
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/Problem"
