openapi: 3.1.0
info:
  title: Apimio Public API
  version: "1.0.0"
  summary: Manage your product catalog programmatically — products, variants, pricing, inventory, media, localization, and webhooks.
  description: |
    The Apimio Public API is a RESTful, JSON-over-HTTPS interface for managing an
    organization's product catalog: products and variants, attributes, media,
    pricing across markets, inventory, localization, and outbound webhooks.

    ## Base URL

    ```
    https://app.apimio.com/api/v1
    ```

    Every path in this document is relative to the base URL. The URL above is
    the one for the environment serving this document, so copy it as-is.

    ## Authentication

    All requests use a **bearer token** in the `Authorization` header:

    ```
    Authorization: Bearer apk_live_xxxxxxxxxxxxxxxxxxxxxxxx
    ```

    Generate a key in **Settings → API & Tokens** (your organization must be on
    the Advanced plan or above, or have API access enabled by an admin). Each
    key is bound to one organization; every request is automatically scoped to
    that organization's catalog.

    ### Scopes

    Every key is minted with a set of granular **scopes**, and each endpoint
    requires one. Scopes are **enforced on every request**: calling an endpoint
    outside the key's scopes returns `403` with `error.code: insufficient_scope`.
    The available scopes are:

    | Scope             | Grants                                                                                  |
    |-------------------|-----------------------------------------------------------------------------------------|
    | `products:read`   | Read products, variants, prices, inventory and reference data                           |
    | `products:write`  | Create/update/delete products, variants, prices, inventory and catalog reference data   |
    | `media:write`     | Attach and remove product media                                                         |
    | `categories:read` | Read categories                                                                         |
    | `attributes:read` | Read attributes                                                                         |
    | `webhooks:read`   | Read webhook endpoints and deliveries                                                   |
    | `webhooks:write`  | Create, update, delete, test and redeliver webhooks                                     |

    Keys minted before granular scoping carry a wildcard (`*`) ability and
    satisfy every scope check (backward-compatible).

    ## Errors

    Errors use a stable envelope. `error.type` is a **coarse category** and is
    safe to branch on; `error.code` is the specific, machine-readable reason
    within that category. `error.message` is human-readable and may change.

    ```json
    {
      "error": {
        "type": "validation_error",
        "code": "validation_failed",
        "message": "The request payload is invalid.",
        "errors": [
          { "field": "title", "code": "invalid", "message": "The title field is required." }
        ]
      }
    }
    ```

    On a `422` the `errors` key is an **array**, one entry per failing field as
    `{ field, code, message }`. It is absent on non-validation errors.

    | Status | `error.type`            | Example `error.code`                                                                        |
    |--------|-------------------------|---------------------------------------------------------------------------------------------|
    | 400    | `invalid_request_error` | `bad_request`                                                                               |
    | 401    | `authentication_error`  | `unauthenticated` (default), `invalid_api_key` (key not bound to an organization)           |
    | 403    | `authorization_error`   | `insufficient_scope`, `plan_not_entitled`, `subscription_inactive`, `organization_access_revoked`, `protected_attribute`, `forbidden` |
    | 404    | `invalid_request_error` | `not_found`                                                                                 |
    | 405    | `invalid_request_error` | `method_not_allowed`                                                                        |
    | 409    | `conflict_error`        | `idempotency_conflict`, `idempotency_in_progress`                                           |
    | 422    | `validation_error`      | `validation_failed`                                                                         |
    | 429    | `rate_limit_error`      | `rate_limit_exceeded`, `quota_exceeded`                                                     |
    | 500    | `api_error`             | `internal_error`                                                                            |

    Two of the `403` codes describe *access* rather than a problem with the
    request. `plan_not_entitled` means the organization's plan does not include
    the Developer API (Advanced PIM and above). `subscription_inactive` means the
    plan does include it, but the subscription is not currently active — your
    existing keys stay valid and start working again as soon as billing is up to
    date, so there is no need to re-issue them.

    ## Rate limits & quota

    Requests are governed by a per-minute **leaky bucket** and a per-month
    **quota**. Every response carries the current state:

    | Header                  | Meaning                                   |
    |-------------------------|-------------------------------------------|
    | `X-RateLimit-Limit`     | Requests allowed per minute               |
    | `X-RateLimit-Remaining` | Tokens left in the current window         |
    | `Retry-After`           | Seconds to wait (only on `429`)           |
    | `X-Quota-Limit`         | Monthly request quota                     |
    | `X-Quota-Remaining`     | Requests left this month                  |

    ## Idempotency

    **Every** `POST` endpoint (not just product creation) honours an
    `Idempotency-Key` header — any unique string, e.g. a UUID. The first
    successful (2xx) response is cached per-organization for 24h and replayed for
    repeat requests carrying the same key (`Idempotent-Replayed: true`), so a
    retried create can't duplicate. Reusing a key with a **different** body
    returns `409` (`error.code: idempotency_conflict`). If a request with the
    same key is still being processed concurrently, the retry returns `409`
    (`error.code: idempotency_in_progress`) — wait briefly and retry.

    ## Pagination

    List endpoints are **cursor-paginated**. Pass `?limit=` and `?cursor=`, then
    follow `meta.next_cursor` (also surfaced as `links.next`). The `products` and
    `webhook-deliveries` lists take `limit` 1–100 (default 20); the reference
    lists (`categories`, `attributes`, `brands`, `vendors`, `webhook-endpoints`)
    take `limit` 1–200 (default 50).

    ```
    GET /products?limit=50
    GET /products?limit=50&cursor=eyJpZCI6MTIzfQ
    ```

    ```json
    {
      "data": [ ... ],
      "meta": { "has_more": true, "next_cursor": "eyJpZCI6MTczfQ", "limit": 50 },
      "links": { "next": "https://app.apimio.com/api/v1/products?limit=50&cursor=eyJpZCI6MTczfQ" }
    }
    ```

    ## Versioning

    The API is versioned in the path (`/v1`). Backwards-compatible changes
    (new fields, new endpoints) ship within `v1`; breaking changes get a `/v2`.

    ## Integrate

    Everything below derives from this one OpenAPI spec, so pick whatever fits
    your workflow:

    - **Postman / Insomnia** — Import → Link → `https://app.apimio.com/developers/openapi.yaml`. You get the full collection; set a bearer-token variable to your key.
    - **Generate an SDK** — `npx @openapitools/openapi-generator-cli generate -i https://app.apimio.com/developers/openapi.yaml -g <typescript-axios|php|python> -o ./apimio-sdk`.
    - **AI tools (Claude Code, Cursor, …)** — use the `@apimio/mcp` server so your assistant can call the API directly. Add to your MCP config:
      ```json
      { "mcpServers": { "apimio": { "command": "npx", "args": ["-y", "@apimio/mcp"], "env": { "APIMIO_API_KEY": "apk_live_…", "APIMIO_BASE_URL": "https://app.apimio.com/api/v1" } } } }
      ```
      `APIMIO_BASE_URL` above already points at the environment serving this document; the package defaults to production if you omit it.
    - **Machine-readable summary** — [`/llms.txt`](https://app.apimio.com/llms.txt) for LLMs, and the raw [`openapi.yaml`](https://app.apimio.com/developers/openapi.yaml).
  contact:
    name: Apimio Support
    url: https://apimio.com
  license:
    name: Proprietary

# The host below is rewritten to the origin serving this document, and
# Production to that environment's name — see DeveloperDocsController.
servers:
  - url: https://app.apimio.com/api/v1
    description: Production

security:
  - bearerAuth: []

tags:
  - name: Products
    description: Create, read, update and delete products.
  - name: Variants
    description: Manage a product's variants.
  - name: Pricing & Markets
    description: Per-market variant pricing.
  - name: Inventory
    description: Per-location stock levels and variant inventory settings.
  - name: Media
    description: Attach and remove product media.
  - name: Localization & Stores
    description: Locales, channels, markets, channel groups, and store-specific overrides.
  - name: Catalog
    description: Categories, brands, vendors and custom attributes.
  - name: Bulk & Jobs
    description: Asynchronous bulk operations and job status.
  - name: Webhooks
    description: Subscribe to catalog events and inspect deliveries.
  - name: Meta
    description: Diagnostics.

paths:
  /ping:
    get:
      tags: [Meta]
      summary: Health/diagnostic check
      description: Confirms the key authenticates and returns the resolved organization.
      operationId: ping
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      organization_id: { type: integer, example: 280 }
                      user_id: { type: integer, example: 42 }
                      products_count: { type: integer, example: 137 }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /products:
    get:
      tags: [Products]
      summary: List products
      operationId: listProducts
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Locale"
        - $ref: "#/components/parameters/Channel"
        - $ref: "#/components/parameters/Group"
        - name: include
          in: query
          description: Comma-separated extra relations to embed (`variants`, `media`).
          schema: { type: string, example: "variants,media" }
        - name: "filter[status]"
          in: query
          description: Filter by status.
          schema: { type: string, enum: [active, draft] }
        - name: "filter[sku]"
          in: query
          description: Substring match on SKU.
          schema: { type: string }
        - name: "filter[search]"
          in: query
          description: Free-text search across SKU, variant SKU/name, and attribute values.
          schema: { type: string }
        - name: "filter[updated_since]"
          in: query
          description: ISO-8601 timestamp — only products updated at/after this time.
          schema: { type: string, format: date-time }
      responses:
        "200":
          description: A page of products.
          headers: { $ref: "#/components/headers/RateLimitSet" }
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Product" }
                  meta: { $ref: "#/components/schemas/PaginationMeta" }
                  links: { $ref: "#/components/schemas/PaginationLinks" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [Products]
      summary: Create a product
      operationId: createProduct
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ProductInput" }
            example:
              title: "Classic Tee"
              description: "A soft cotton tee."
              status: active
              product_type: "Apparel"
              brand: "Acme"
              vendor: "Acme Supply"
              categories: ["Shirts"]
              seo: { title: "Classic Tee", description: "Buy the classic tee", slug: "classic-tee" }
              attributes: { material: "Cotton", fit: "Regular" }
              price: 19.99
      responses:
        "201":
          description: Created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Product" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/IdempotencyConflict" }
        "422": { $ref: "#/components/responses/ValidationError" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /products/{product}:
    parameters:
      - $ref: "#/components/parameters/ProductId"
    get:
      tags: [Products]
      summary: Retrieve a product
      operationId: getProduct
      parameters:
        - $ref: "#/components/parameters/Locale"
        - $ref: "#/components/parameters/Channel"
        - $ref: "#/components/parameters/Group"
      responses:
        "200":
          description: The product (variants + media embedded).
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Product" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Products]
      summary: Update a product
      description: Only the fields you send are changed. Send `null` to clear a relation.
      operationId: updateProduct
      parameters:
        - $ref: "#/components/parameters/Locale"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ProductInput" }
            example: { title: "Classic Tee (2024)", status: active }
      responses:
        "200":
          description: Updated.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Product" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }
    delete:
      tags: [Products]
      summary: Delete a product
      operationId: deleteProduct
      responses:
        "200":
          description: Deleted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id: { type: integer, example: 123 }
                      deleted: { type: boolean, example: true }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /products/{product}/variants:
    parameters:
      - $ref: "#/components/parameters/ProductId"
    post:
      tags: [Variants]
      summary: Add a variant
      operationId: createVariant
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/VariantInput" }
            example: { sku: "TEE-RED-S", title: "Red / S", price: 19.99, options: ["Red", "S"] }
      responses:
        "201":
          description: Created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Variant" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /variants/{variant}:
    parameters:
      - $ref: "#/components/parameters/VariantId"
    patch:
      tags: [Variants]
      summary: Update a variant
      operationId: updateVariant
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/VariantInput" }
            example: { price: 17.99, quantity: 42 }
      responses:
        "200":
          description: Updated.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Variant" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }
    delete:
      tags: [Variants]
      summary: Delete a variant
      operationId: deleteVariant
      responses:
        "200":
          description: Deleted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id: { type: integer }
                      deleted: { type: boolean, example: true }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /variants/{variant}/prices:
    parameters:
      - $ref: "#/components/parameters/VariantId"
    get:
      tags: [Pricing & Markets]
      summary: List a variant's market prices
      operationId: getVariantPrices
      responses:
        "200":
          description: Per-market prices (markets with no explicit price inherit the canonical price).
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/MarketPrice" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    put:
      tags: [Pricing & Markets]
      summary: Set a variant's market prices
      description: Upsert prices per market. A price of `0`/`null` deletes the override so that market inherits the canonical price.
      operationId: setVariantPrices
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [prices]
              properties:
                prices:
                  type: array
                  minItems: 1
                  maxItems: 100
                  items:
                    type: object
                    required: [market]
                    properties:
                      market: { type: integer, description: Market id. Unknown/cross-org markets are ignored. }
                      price: { type: [number, "null"], description: "Per-market price. 0/null clears the override (inherit canonical)." }
                      compare_at_price: { type: [number, "null"] }
            example: { prices: [ { market: 5, price: 24.99, compare_at_price: 29.99 }, { market: 6, price: null } ] }
      responses:
        "200":
          description: Updated prices.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/MarketPrice" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /variants/{variant}/inventory:
    parameters:
      - $ref: "#/components/parameters/VariantId"
    get:
      tags: [Inventory]
      summary: Get a variant's inventory
      operationId: getVariantInventory
      responses:
        "200":
          description: Per-location stock + tracking settings.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Inventory" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    put:
      tags: [Inventory]
      summary: Set a variant's inventory
      description: Update per-location quantities and/or the variant's tracking settings.
      operationId: setVariantInventory
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                tracked: { type: boolean, description: Whether Apimio tracks this variant's quantity. }
                continue_selling: { type: boolean, description: Allow overselling when out of stock. }
                levels:
                  type: array
                  items:
                    type: object
                    required: [location, quantities]
                    properties:
                      location: { type: integer, description: A channel-location id (see GET inventory). Unknown/cross-org locations are ignored. }
                      quantities:
                        type: object
                        description: |
                          Per-state quantities to write. Keys are inventory states;
                          only the writable states are applied (`committed` is
                          read-only — it is set by orders). Any other key is ignored.
                        properties:
                          available: { type: [integer, "null"], minimum: 0 }
                          on_hand: { type: [integer, "null"], minimum: 0 }
                          incoming: { type: [integer, "null"], minimum: 0 }
                          reserved: { type: [integer, "null"], minimum: 0 }
                          damaged: { type: [integer, "null"], minimum: 0 }
                          safety_stock: { type: [integer, "null"], minimum: 0 }
                          quality_control: { type: [integer, "null"], minimum: 0 }
            example: { tracked: true, continue_selling: false, levels: [ { location: 12, quantities: { available: 100, on_hand: 100 } } ] }
      responses:
        "200":
          description: Updated inventory.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Inventory" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /products/{product}/overrides:
    parameters:
      - $ref: "#/components/parameters/ProductId"
    put:
      tags: [Localization & Stores]
      summary: Set store-specific overrides
      description: |
        Override native fields (title/description/seo) for a specific channel or
        channel-group, optionally for one locale. Omitted fields are untouched.
      operationId: setProductOverrides
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [scope, fields]
              properties:
                scope: { $ref: "#/components/schemas/Scope" }
                fields:
                  type: object
                  properties:
                    title: { type: string }
                    description: { type: string }
                    seo_title: { type: string }
                    seo_description: { type: string }
            example:
              scope: { channel: 42, locale: fr }
              fields: { title: "T-shirt Classique", description: "Un t-shirt en coton doux." }
      responses:
        "200":
          description: Overrides written.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      ok: { type: boolean }
                      scope: { $ref: "#/components/schemas/Scope" }
                      updated: { type: array, items: { type: string } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }
    delete:
      tags: [Localization & Stores]
      summary: Clear store-specific overrides
      operationId: clearProductOverrides
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [scope, fields]
              properties:
                scope: { $ref: "#/components/schemas/Scope" }
                fields:
                  type: array
                  items: { type: string, enum: [title, description, seo_title, seo_description] }
            example: { scope: { channel: 42, locale: fr }, fields: ["title", "description"] }
      responses:
        "200":
          description: Cleared.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: object, properties: { ok: { type: boolean } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /products/{product}/media:
    parameters:
      - $ref: "#/components/parameters/ProductId"
    post:
      tags: [Media]
      summary: Attach media by URL
      operationId: attachMedia
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url: { type: string, format: uri }
                alt: { type: string }
            example: { url: "https://cdn.example.com/tee.jpg", alt: "Classic Tee front" }
      responses:
        "201":
          description: Attached.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Media" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /products/{product}/media/{media}:
    parameters:
      - $ref: "#/components/parameters/ProductId"
      - name: media
        in: path
        required: true
        schema: { type: integer }
    delete:
      tags: [Media]
      summary: Remove media from a product
      operationId: deleteMedia
      responses:
        "200":
          description: Removed.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: object, properties: { id: { type: integer }, deleted: { type: boolean } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /categories:
    get:
      tags: [Catalog]
      summary: List categories
      operationId: listCategories
      parameters:
        - $ref: "#/components/parameters/RefLimit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: A page of categories.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: "#/components/schemas/Category" } }
                  meta: { $ref: "#/components/schemas/PaginationMeta" }
                  links: { $ref: "#/components/schemas/PaginationLinks" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      tags: [Catalog]
      summary: Create a category
      operationId: createCategory
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [name], properties: { name: { type: string } } }
            example: { name: "Shirts" }
      responses:
        "201":
          description: Created.
          content:
            application/json:
              schema: { type: object, properties: { data: { $ref: "#/components/schemas/Category" } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /categories/{id}:
    parameters: [ { $ref: "#/components/parameters/ResourceId" } ]
    patch:
      tags: [Catalog]
      summary: Update a category
      operationId: updateCategory
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, properties: { name: { type: string } } }
      responses:
        "200":
          description: Updated.
          content:
            application/json:
              schema: { type: object, properties: { data: { $ref: "#/components/schemas/Category" } } }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Catalog]
      summary: Delete a category
      operationId: deleteCategory
      responses:
        "200": { description: Deleted., content: { application/json: { schema: { $ref: "#/components/schemas/DeletedResult" } } } }
        "404": { $ref: "#/components/responses/NotFound" }

  /brands:
    get:
      tags: [Catalog]
      summary: List brands
      operationId: listBrands
      parameters:
        - $ref: "#/components/parameters/RefLimit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: A page of brands.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: "#/components/schemas/NamedRef" } }
                  meta: { $ref: "#/components/schemas/PaginationMeta" }
                  links: { $ref: "#/components/schemas/PaginationLinks" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      tags: [Catalog]
      summary: Create a brand
      operationId: createBrand
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [name], properties: { name: { type: string } } }
      responses:
        "201": { description: Created., content: { application/json: { schema: { type: object, properties: { data: { $ref: "#/components/schemas/NamedRef" } } } } } }
        "422": { $ref: "#/components/responses/ValidationError" }

  /brands/{id}:
    parameters: [ { $ref: "#/components/parameters/ResourceId" } ]
    patch:
      tags: [Catalog]
      summary: Update a brand
      operationId: updateBrand
      requestBody: { required: true, content: { application/json: { schema: { type: object, properties: { name: { type: string } } } } } }
      responses:
        "200": { description: Updated., content: { application/json: { schema: { type: object, properties: { data: { $ref: "#/components/schemas/NamedRef" } } } } } }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Catalog]
      summary: Delete a brand
      operationId: deleteBrand
      responses:
        "200": { description: Deleted., content: { application/json: { schema: { $ref: "#/components/schemas/DeletedResult" } } } }
        "404": { $ref: "#/components/responses/NotFound" }

  /vendors:
    get:
      tags: [Catalog]
      summary: List vendors
      operationId: listVendors
      parameters:
        - $ref: "#/components/parameters/RefLimit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: A page of vendors.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: "#/components/schemas/NamedRef" } }
                  meta: { $ref: "#/components/schemas/PaginationMeta" }
                  links: { $ref: "#/components/schemas/PaginationLinks" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      tags: [Catalog]
      summary: Create a vendor
      operationId: createVendor
      requestBody: { required: true, content: { application/json: { schema: { type: object, required: [name], properties: { name: { type: string } } } } } }
      responses:
        "201": { description: Created., content: { application/json: { schema: { type: object, properties: { data: { $ref: "#/components/schemas/NamedRef" } } } } } }
        "422": { $ref: "#/components/responses/ValidationError" }

  /vendors/{id}:
    parameters: [ { $ref: "#/components/parameters/ResourceId" } ]
    patch:
      tags: [Catalog]
      summary: Update a vendor
      operationId: updateVendor
      requestBody: { required: true, content: { application/json: { schema: { type: object, properties: { name: { type: string } } } } } }
      responses:
        "200": { description: Updated., content: { application/json: { schema: { type: object, properties: { data: { $ref: "#/components/schemas/NamedRef" } } } } } }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Catalog]
      summary: Delete a vendor
      operationId: deleteVendor
      responses:
        "200": { description: Deleted., content: { application/json: { schema: { $ref: "#/components/schemas/DeletedResult" } } } }
        "404": { $ref: "#/components/responses/NotFound" }

  /attributes:
    get:
      tags: [Catalog]
      summary: List custom attributes
      description: Returns custom product attributes only (native/category/variant-scope excluded).
      operationId: listAttributes
      parameters:
        - $ref: "#/components/parameters/RefLimit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: A page of attributes.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: "#/components/schemas/Attribute" } }
                  meta: { $ref: "#/components/schemas/PaginationMeta" }
                  links: { $ref: "#/components/schemas/PaginationLinks" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      tags: [Catalog]
      summary: Create a custom attribute
      operationId: createAttribute
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, maxLength: 255 }
                type_id:
                  type: integer
                  enum: [1, 3, 4, 11, 14, 15]
                  default: 1
                  description: "Attribute type id (defaults to 1 = single-line text). Restricted to safe text-ish types; variant-option type 13 is never allowed."
                is_localizable: { type: boolean, description: Whether the attribute can hold per-locale values. }
            example: { name: "Material", type_id: 1, is_localizable: false }
      responses:
        "201": { description: Created., content: { application/json: { schema: { type: object, properties: { data: { $ref: "#/components/schemas/Attribute" } } } } } }
        "422": { $ref: "#/components/responses/ValidationError" }

  /attributes/{id}:
    parameters: [ { $ref: "#/components/parameters/ResourceId" } ]
    delete:
      tags: [Catalog]
      summary: Delete a custom attribute
      operationId: deleteAttribute
      responses:
        "200": { description: Deleted., content: { application/json: { schema: { $ref: "#/components/schemas/DeletedResult" } } } }
        "404": { $ref: "#/components/responses/NotFound" }

  /locales:
    get:
      tags: [Localization & Stores]
      summary: List locales
      operationId: listLocales
      responses:
        "200": { description: Locales (code + primary flag)., content: { application/json: { schema: { type: object, properties: { data: { type: array, items: { type: object, properties: { code: { type: string, example: en }, name: { type: string }, is_primary: { type: boolean } } } } } } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /channels:
    get:
      tags: [Localization & Stores]
      summary: List channels (stores)
      operationId: listChannels
      responses:
        "200":
          description: Channels.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: integer }
                        name: { type: string }
                        type: { type: [string, "null"], description: "Channel type (e.g. shopify, manual)." }
                        is_primary: { type: boolean }
                        currency: { type: [string, "null"], description: Default currency code. }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /channels/{channel}/markets:
    parameters:
      - name: channel
        in: path
        required: true
        schema: { type: integer }
    get:
      tags: [Localization & Stores]
      summary: List a channel's markets
      operationId: listChannelMarkets
      responses:
        "200":
          description: Markets.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: integer }
                        name: { type: string }
                        currency: { type: [string, "null"] }
                        is_primary: { type: boolean }
                        pricing_strategy: { type: [string, "null"], description: How this market's prices are derived. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /channel-groups:
    get:
      tags: [Localization & Stores]
      summary: List channel groups
      operationId: listChannelGroups
      responses:
        "200":
          description: Channel groups.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: integer }
                        name: { type: string }
                        channels:
                          type: array
                          items: { type: integer }
                          description: Ids of the channels in this group.
        "401": { $ref: "#/components/responses/Unauthorized" }

  /products/bulk:
    post:
      tags: [Bulk & Jobs]
      summary: Bulk-create products
      description: Accepts up to 500 products and processes them asynchronously. Poll the returned job for progress.
      operationId: bulkCreateProducts
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [products]
              properties:
                products:
                  type: array
                  minItems: 1
                  maxItems: 500
                  items: { $ref: "#/components/schemas/ProductInput" }
            example: { products: [ { title: "Tee A" }, { title: "Tee B" } ] }
      responses:
        "202":
          description: Accepted — processing asynchronously.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      job_id: { type: integer }
                      status: { type: string, example: accepted }
                      total: { type: integer }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /jobs/{id}:
    parameters: [ { $ref: "#/components/parameters/ResourceId" } ]
    get:
      tags: [Bulk & Jobs]
      summary: Get job status
      operationId: getJob
      responses:
        "200":
          description: Job progress.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Job" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /webhook-endpoints:
    get:
      tags: [Webhooks]
      summary: List webhook endpoints
      operationId: listWebhookEndpoints
      parameters:
        - $ref: "#/components/parameters/RefLimit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: A page of endpoints.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: "#/components/schemas/WebhookEndpoint" } }
                  meta: { $ref: "#/components/schemas/PaginationMeta" }
                  links: { $ref: "#/components/schemas/PaginationLinks" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      tags: [Webhooks]
      summary: Create a webhook endpoint
      description: The signing `secret` is returned **once** in this response and never again. Store it to verify signatures.
      operationId: createWebhookEndpoint
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookEndpointInput" }
            example: { url: "https://example.com/hooks/apimio", events: ["product.created", "product.updated"], description: "Sync to ERP" }
      responses:
        "201":
          description: Created (with the one-time secret).
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/WebhookEndpoint" }
                  secret: { type: string, example: "whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /webhook-endpoints/{endpoint}:
    parameters:
      - name: endpoint
        in: path
        required: true
        schema: { type: integer }
    get:
      tags: [Webhooks]
      summary: Retrieve a webhook endpoint
      operationId: getWebhookEndpoint
      responses:
        "200": { description: Endpoint., content: { application/json: { schema: { type: object, properties: { data: { $ref: "#/components/schemas/WebhookEndpoint" } } } } } }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Webhooks]
      summary: Update a webhook endpoint
      operationId: updateWebhookEndpoint
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookEndpointInput" }
            example: { status: disabled }
      responses:
        "200": { description: Updated., content: { application/json: { schema: { type: object, properties: { data: { $ref: "#/components/schemas/WebhookEndpoint" } } } } } }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }
    delete:
      tags: [Webhooks]
      summary: Delete a webhook endpoint
      operationId: deleteWebhookEndpoint
      responses:
        "200": { description: Deleted., content: { application/json: { schema: { $ref: "#/components/schemas/DeletedResult" } } } }
        "404": { $ref: "#/components/responses/NotFound" }

  /webhook-endpoints/{endpoint}/test:
    parameters:
      - name: endpoint
        in: path
        required: true
        schema: { type: integer }
    post:
      tags: [Webhooks]
      summary: Send a test (ping) event
      operationId: testWebhookEndpoint
      responses:
        "202":
          description: Test delivery queued.
          content:
            application/json:
              schema: { type: object, properties: { data: { $ref: "#/components/schemas/WebhookDelivery" } } }
        "404": { $ref: "#/components/responses/NotFound" }

  /webhook-deliveries:
    get:
      tags: [Webhooks]
      summary: List webhook deliveries
      operationId: listWebhookDeliveries
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - name: endpoint_id
          in: query
          schema: { type: integer }
        - name: status
          in: query
          schema: { type: string, enum: [pending, success, failed, exhausted, skipped] }
        - name: event_type
          in: query
          schema: { type: string }
      responses:
        "200":
          description: A page of deliveries.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: "#/components/schemas/WebhookDelivery" } }
                  meta: { $ref: "#/components/schemas/PaginationMeta" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /webhook-deliveries/{delivery}:
    parameters:
      - name: delivery
        in: path
        required: true
        schema: { type: integer }
    get:
      tags: [Webhooks]
      summary: Retrieve a delivery
      operationId: getWebhookDelivery
      responses:
        "200": { description: Delivery (with payload)., content: { application/json: { schema: { type: object, properties: { data: { $ref: "#/components/schemas/WebhookDelivery" } } } } } }
        "404": { $ref: "#/components/responses/NotFound" }

  /webhook-deliveries/{delivery}/redeliver:
    parameters:
      - name: delivery
        in: path
        required: true
        schema: { type: integer }
    post:
      tags: [Webhooks]
      summary: Redeliver an event
      operationId: redeliverWebhook
      responses:
        "202": { description: Re-queued., content: { application/json: { schema: { type: object, properties: { data: { $ref: "#/components/schemas/WebhookDelivery" } } } } } }
        "404": { $ref: "#/components/responses/NotFound" }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: A personal API key from Settings → API & Tokens.

  parameters:
    Limit:
      name: limit
      in: query
      description: Items per page (1–100).
      schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
    RefLimit:
      name: limit
      in: query
      description: Items per page for reference lists (1–200).
      schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
    Cursor:
      name: cursor
      in: query
      description: Opaque cursor from a previous response's `meta.next_cursor`.
      schema: { type: string }
    Locale:
      name: locale
      in: query
      description: Locale code (e.g. `fr`). Returns/writes that locale's values, falling back to the primary.
      schema: { type: string, example: fr }
    Channel:
      name: channel
      in: query
      description: Channel (store) id — layers that store's overrides on top.
      schema: { type: integer }
    Group:
      name: group
      in: query
      description: Channel-group id — layers the group's overrides on top.
      schema: { type: integer }
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      description: Unique key making this POST safely retryable for 24h.
      schema: { type: string, example: "a1b2c3d4-e5f6-7890-abcd-ef1234567890" }
    ProductId:
      name: product
      in: path
      required: true
      description: Product id.
      schema: { type: integer }
    VariantId:
      name: variant
      in: path
      required: true
      description: Variant id.
      schema: { type: integer }
    ResourceId:
      name: id
      in: path
      required: true
      description: Resource id.
      schema: { type: integer }

  headers:
    RateLimitSet:
      X-RateLimit-Limit:
        description: Requests allowed per minute.
        schema: { type: integer }
      X-RateLimit-Remaining:
        description: Tokens remaining in the current window.
        schema: { type: integer }
      X-Quota-Remaining:
        description: Requests remaining this month.
        schema: { type: integer }

  schemas:
    Error:
      type: object
      properties:
        error:
          type: object
          properties:
            type:
              type: string
              description: Coarse error category — safe to branch on.
              enum: [authentication_error, authorization_error, invalid_request_error, conflict_error, rate_limit_error, validation_error, api_error]
              example: invalid_request_error
            code: { type: string, description: Specific machine-readable reason within the category., example: not_found }
            message: { type: string, example: "The requested resource was not found." }
            errors:
              type: array
              description: Present only on 422 validation errors — one entry per failing field.
              items:
                type: object
                properties:
                  field: { type: string, example: title }
                  code: { type: string, example: invalid }
                  message: { type: string, example: "The title field is required." }
    PaginationMeta:
      type: object
      properties:
        has_more: { type: boolean }
        next_cursor: { type: [string, "null"], description: Pass as `?cursor=` for the next page. }
        limit: { type: integer }
    PaginationLinks:
      type: object
      properties:
        next: { type: [string, "null"], description: "Absolute URL for the next page, or null on the last page." }
    Product:
      type: object
      properties:
        id: { type: integer, example: 123 }
        sku: { type: string, example: "classic-tee" }
        title: { type: string, example: "Classic Tee" }
        description: { type: [string, "null"] }
        status: { type: string, enum: [active, draft] }
        product_type: { type: [string, "null"], example: "Apparel" }
        seo:
          type: [object, "null"]
          properties:
            title: { type: string }
            description: { type: string }
            slug: { type: string }
        attributes:
          type: object
          additionalProperties: true
          description: Custom attributes as a handle → value map (string or array for multi-value).
          example: { material: "Cotton", fit: "Regular" }
        brand: { type: [string, "null"], example: "Acme" }
        vendor: { type: [string, "null"], example: "Acme Supply" }
        categories:
          type: array
          items: { $ref: "#/components/schemas/Category" }
        variants:
          type: array
          description: Present on detail responses and when `include=variants`.
          items: { $ref: "#/components/schemas/Variant" }
        media:
          type: array
          description: Present on detail responses and when `include=media`.
          items: { $ref: "#/components/schemas/Media" }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        scope:
          type: object
          description: Present only when a `?locale`, `?channel`, or `?group` query param is supplied — echoes the resolved scope.
          properties:
            locale: { type: [string, "null"] }
            channel: { type: [integer, "null"] }
            group: { type: [integer, "null"] }
        overrides:
          type: [object, "null"]
          description: Present only when a scope param is supplied — per-field resolution info for the native fields.
          properties:
            title: { $ref: "#/components/schemas/OverrideMeta" }
            description: { $ref: "#/components/schemas/OverrideMeta" }
            seo_title: { $ref: "#/components/schemas/OverrideMeta" }
            seo_description: { $ref: "#/components/schemas/OverrideMeta" }
    OverrideMeta:
      type: object
      properties:
        source: { type: string, description: "Where the resolved value came from (e.g. canonical, channel_override, group_override)." }
        is_override: { type: boolean, description: true when a channel/group override supplied the value. }
    ProductInput:
      type: object
      required: [title]
      properties:
        title: { type: string, maxLength: 255 }
        description: { type: string }
        sku: { type: string, description: "Defaults to a slug of the title if omitted." }
        status: { type: string, enum: [active, draft], default: draft }
        product_type: { type: string }
        brand: { type: [string, "null"], description: "Brand name (auto-created). null clears it." }
        vendor: { type: [string, "null"], description: "Vendor name (auto-created). null clears it." }
        categories:
          type: array
          items: { type: [string, integer] }
          description: Category names (auto-created) or ids.
        seo:
          type: object
          properties:
            title: { type: string }
            description: { type: string }
            slug: { type: string }
        attributes:
          type: object
          additionalProperties: true
          description: Custom attributes by handle. Native/category/variant handles are rejected (422).
        price:
          type: number
          description: Convenience price for the default variant (create only).
        variants:
          type: array
          maxItems: 100
          description: Optional inline variants to create with the product (create only).
          items: { $ref: "#/components/schemas/VariantInput" }
    Variant:
      type: object
      properties:
        id: { type: integer }
        sku: { type: [string, "null"] }
        title: { type: [string, "null"], example: "Red / S" }
        price: { type: [number, "null"], example: 19.99 }
        compare_at_price: { type: [number, "null"] }
        cost_price: { type: [number, "null"] }
        barcode: { type: [string, "null"] }
        weight: { type: [number, "null"] }
        weight_unit: { type: [string, "null"] }
        quantity: { type: [integer, "null"] }
        options:
          type: array
          items: { type: string }
          example: ["Red", "S"]
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    VariantInput:
      type: object
      properties:
        sku: { type: string }
        title: { type: string }
        price: { type: number }
        compare_at_price: { type: number }
        cost_price: { type: number }
        barcode: { type: string }
        weight: { type: number }
        weight_unit: { type: string }
        quantity: { type: integer }
        options:
          type: array
          items: { type: string }
    Media:
      type: object
      properties:
        id: { type: integer }
        url: { type: string, format: uri }
        alt: { type: [string, "null"] }
        width: { type: [integer, "null"] }
        height: { type: [integer, "null"] }
    Category:
      type: object
      properties:
        id: { type: integer }
        name: { type: string, example: "Shirts" }
        description: { type: [string, "null"] }
    NamedRef:
      type: object
      properties:
        id: { type: integer }
        name: { type: string }
    DeletedResult:
      type: object
      properties:
        data:
          type: object
          properties:
            id: { type: integer }
            deleted: { type: boolean, example: true }
    Attribute:
      type: object
      properties:
        id: { type: integer }
        handle: { type: string, example: material }
        name: { type: string, example: Material }
        type_id: { type: [integer, "null"], description: Attribute type id., example: 1 }
        is_localizable: { type: boolean }
    MarketPrice:
      type: object
      properties:
        market_id: { type: integer }
        market: { type: string, description: Market name., example: "United States" }
        currency: { type: [string, "null"], example: USD }
        price: { type: [string, "null"], description: "Effective price, serialized as a decimal string.", example: "24.99" }
        compare_at_price: { type: [string, "null"], description: Serialized as a decimal string when set. }
        source: { type: string, enum: [market, inherited], description: "`market` when this market has an explicit override; `inherited` when it falls back to the canonical variant price." }
    Inventory:
      type: object
      properties:
        tracked: { type: boolean, description: Whether Apimio tracks this variant's quantity. }
        continue_selling: { type: boolean }
        levels:
          type: array
          items:
            type: object
            description: Per-location stock. `committed` is read-only (set by orders).
            properties:
              location: { type: integer, description: A channel-location id. }
              location_name: { type: [string, "null"] }
              channel_id: { type: [integer, "null"] }
              available: { type: [integer, "null"] }
              on_hand: { type: [integer, "null"] }
              committed: { type: [integer, "null"] }
              incoming: { type: [integer, "null"] }
              reserved: { type: [integer, "null"] }
              damaged: { type: [integer, "null"] }
              safety_stock: { type: [integer, "null"] }
              quality_control: { type: [integer, "null"] }
    Scope:
      type: object
      description: Targets a store context. Provide `channel` OR `group` (omit both for canonical), plus optional `locale`.
      properties:
        channel: { type: integer }
        group: { type: integer }
        locale: { type: string, example: fr }
    Job:
      type: object
      properties:
        id: { type: integer }
        type: { type: string, example: api_bulk_products }
        status: { type: string, enum: [processing, completed] }
        total: { type: integer }
        processed: { type: integer }
        created: { type: integer }
        updated: { type: integer }
        errors: { type: integer }
        progress_percent: { type: integer, example: 100 }
        completed_at: { type: [string, "null"], format: date-time }
    WebhookEndpoint:
      type: object
      properties:
        id: { type: integer }
        url: { type: string, format: uri }
        description: { type: [string, "null"] }
        events:
          type: array
          items: { type: string }
          description: Subscribed event types, or `["*"]` for all.
          example: ["product.created", "product.updated"]
        status: { type: string, enum: [active, disabled] }
        consecutive_failures: { type: integer }
        last_success_at: { type: [string, "null"], format: date-time }
        last_failure_at: { type: [string, "null"], format: date-time }
        created_at: { type: string, format: date-time }
    WebhookEndpointInput:
      type: object
      properties:
        url: { type: string, format: uri }
        description: { type: string }
        events:
          type: array
          items:
            type: string
            enum: [product.created, product.updated, product.deleted, variant.created, variant.updated, variant.deleted, "*"]
          description: Defaults to all events if omitted.
        status: { type: string, enum: [active, disabled] }
    WebhookDelivery:
      type: object
      properties:
        id: { type: integer }
        webhook_endpoint_id: { type: integer }
        event_id: { type: string, format: uuid }
        event_type: { type: string, example: product.created }
        status: { type: string, enum: [pending, success, failed, exhausted, skipped] }
        attempts: { type: integer }
        response_status: { type: [integer, "null"] }
        response_body: { type: [string, "null"] }
        error: { type: [string, "null"] }
        next_attempt_at: { type: [string, "null"], format: date-time }
        delivered_at: { type: [string, "null"], format: date-time }
        created_at: { type: string, format: date-time }
        payload: { $ref: "#/components/schemas/Event" }
    Event:
      type: object
      description: The signed JSON body POSTed to your endpoint.
      properties:
        id: { type: string, format: uuid, description: Stable event id — dedupe on this. }
        type: { type: string, example: product.created }
        api_version: { type: string, example: v1 }
        created_at: { type: string, format: date-time }
        organization_id: { type: integer }
        data:
          type: object
          description: "The resource (a Product or Variant); for deletes it is a stub with id, object and deleted: true."
          additionalProperties: true

  responses:
    Unauthorized:
      description: Missing, invalid, or revoked API key.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { error: { type: authentication_error, code: unauthenticated, message: "Authentication required." } }
    Forbidden:
      description: Org not entitled to the API, or the key lacks the required scope.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { error: { type: authorization_error, code: insufficient_scope, message: "This API key is missing the required scope: products:write." } }
    NotFound:
      description: Resource not found (or not in your organization).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { error: { type: invalid_request_error, code: not_found, message: "The requested resource was not found." } }
    ValidationError:
      description: One or more fields failed validation.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example:
            error:
              type: validation_error
              code: validation_failed
              message: "The request payload is invalid."
              errors:
                - { field: title, code: invalid, message: "The title field is required." }
    IdempotencyConflict:
      description: The Idempotency-Key was reused with a different request body, or a request with the same key is still being processed.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { error: { type: conflict_error, code: idempotency_conflict, message: "This Idempotency-Key was already used with a different request body." } }
    RateLimited:
      description: Rate limit or monthly quota exceeded.
      headers:
        Retry-After:
          description: Seconds to wait before retrying.
          schema: { type: integer }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { error: { type: rate_limit_error, code: rate_limit_exceeded, message: "API rate limit exceeded. Retry after the indicated delay." } }
