openapi: 3.1.0
info:
  title: Nembol API
  version: "1.0.0"
  description: |
    The Nembol API lets you manage products and images, publish listings to
    every connected sales channel, sync stock and prices, read orders, and
    subscribe to signed webhooks.

    ## Base URL
    `https://api.nembol.com/rest/v1`

    ## Authentication
    Bearer API keys (`nbl_live_...` / `nbl_test_...`), created in the Nembol
    dashboard under Settings → Developers. Keys are store-scoped and carry
    scopes. Never use keys in browser code.

    ## Object model
    Five nouns and one rule. **Product** — your canonical catalog item, with
    embedded `images[]` and `variants[]`. **Channel account** — one connected
    channel account (two eBay accounts are two channel accounts); referenced
    everywhere by its opaque stable id. **Listing** — the projection of one
    product on one channel account, with per-channel overrides and issues.
    **Operation** — the tracker for asynchronous channel work. **Event** —
    the durable log entry behind webhooks. The rule: local writes are applied
    immediately; channel propagation is asynchronous and tracked by an
    operation.

    ## Conventions
    - IDs are type-prefixed and opaque: `prod_`, `var_`, `img_`, `list_`,
      `chac_`, `ord_`, `op_`, `evt_`, `whe_`, `file_`. Every resource carries
      an `object` field naming its type.
    - JSON only, `snake_case`, timestamps ISO-8601 UTC.
    - Money amounts are decimal **strings** with 2 decimals (e.g. `"19.90"`).
      Product prices are in the store base currency (`GET /store` →
      `currency`); order amounts carry their own `currency`.
    - Cursor pagination: `?limit=` (max 100) and `?cursor=`; responses carry
      `has_more` and `next_cursor`. Ordering is stable (`updated_at` desc,
      `id` tiebreak).
    - `Idempotency-Key` accepted on every mutating request: replaying the
      same key with the same payload returns the stored original response;
      the same key with a different payload fails with `409`
      (`idempotency_key_reused`). Keys live 24h.
    - Enum extensibility: new enum values, event types and response fields
      are **non-breaking**. Clients must tolerate unknowns.
    - Error messages are English; the machine contract is `error.code`.

    ## Rate limiting
    Leaky-bucket per API key. Every response carries `X-RateLimit-Limit`,
    `X-RateLimit-Remaining` and `X-RateLimit-Reset` (Unix epoch seconds).
    `429` responses include an exact `Retry-After`. A separate daily write
    quota applies to channel-mutating requests.
  contact:
    name: Nembol API support
    email: developer@nembol.com
  termsOfService: https://www.nembol.com/api-terms

servers:
  - url: https://api.nembol.com/rest/v1
    description: Production
  - url: https://p-newui.nembol.com/rest/v1
    description: Staging (beta testing)

security:
  - apiKey: []

tags:
  - name: Store
  - name: Channel accounts
    description: >
      Connected channel accounts and per-account discovery: categories,
      attribute schemas and policies. Read-only — connecting accounts happens
      in the Nembol dashboard.
  - name: Products
  - name: Files
  - name: Listings
    description: >
      A listing is one product on one channel account. Publish by creating a
      listing; delist by deleting it (the record is kept with status
      `delisted` — publish again by re-creating). Listings carry per-channel
      overrides, per-variant channel ids, and `issues[]` explaining failures.
  - name: Inventory
  - name: Orders
  - name: Operations
  - name: Webhooks
  - name: Events

paths:
  /health:
    get:
      tags: [Store]
      operationId: getHealth
      summary: API health check
      security: []
      description: >
        Unauthenticated liveness probe. Returns `ok` when the API and its
        database are reachable. Powers the Status page on this site.
      responses:
        "200":
          description: API operational.
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { type: string, const: health }
                  status: { type: string, enum: [ok, degraded] }
                  time: { type: string, format: date-time }
        "503":
          description: API degraded (database unreachable).

  /store:
    get:
      tags: [Store]
      operationId: getStore
      summary: Retrieve the store
      description: >
        Returns the store this API key belongs to, its plan, base currency
        and the scopes of the current key. Canonical "test your key"
        endpoint.
      responses:
        "200":
          description: The store.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Store" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /channel_accounts:
    get:
      tags: [Channel accounts]
      operationId: listChannelAccounts
      summary: List channel accounts
      description: >
        One entry per connected account. A seller with two eBay accounts has
        two entries with different ids. Always take `id` from here — never
        construct it.
      responses:
        "200":
          description: Connected channel accounts.
          content:
            application/json:
              schema:
                type: object
                required: [object, data]
                properties:
                  object: { type: string, const: list }
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/ChannelAccount" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /channel_accounts/{id}:
    get:
      tags: [Channel accounts]
      operationId: getChannelAccount
      summary: Retrieve a channel account
      parameters: [{ $ref: "#/components/parameters/ChannelAccountId" }]
      responses:
        "200":
          description: The channel account.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ChannelAccount" }
        "404": { $ref: "#/components/responses/NotFound" }

  /channel_accounts/{id}/categories:
    get:
      tags: [Channel accounts]
      operationId: listCategories
      summary: Search channel categories
      description: >
        Search the channel's category tree (e.g. eBay categories, Etsy
        taxonomy, Kaufland categories). Support varies by channel — see the
        `discovery` flags on the channel account. Channels without a category
        model (e.g. Shopify) return an empty list.
      parameters:
        - $ref: "#/components/parameters/ChannelAccountId"
        - name: q
          in: query
          description: Free-text search.
          schema: { type: string }
        - name: parent_id
          in: query
          description: List children of a category instead of searching.
          schema: { type: string }
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: Matching categories.
          content:
            application/json:
              schema:
                type: object
                required: [object, data]
                properties:
                  object: { type: string, const: list }
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Category" }
        "404": { $ref: "#/components/responses/NotFound" }

  /channel_accounts/{id}/categories/{category_id}/attribute_schema:
    get:
      tags: [Channel accounts]
      operationId: getAttributeSchema
      summary: Retrieve the attribute schema for a category
      description: >
        The JSON Schema describing which listing `attributes` the channel
        requires or accepts for this category (eBay item aspects, Etsy
        properties, Kaufland/OnBuy attributes…). Validate listing attributes
        against it before publishing; Nembol validates at write time with
        the same schema.
      parameters:
        - $ref: "#/components/parameters/ChannelAccountId"
        - name: category_id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: The attribute schema.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AttributeSchema" }
        "404": { $ref: "#/components/responses/NotFound" }

  /channel_accounts/{id}/policies:
    get:
      tags: [Channel accounts]
      operationId: listPolicies
      summary: List channel policies
      description: >
        Shipping, return and payment policies stored on the channel account
        (eBay Business Policies, Etsy shipping profiles…). Reference them by
        id in listing overrides where supported. Read-only.
      parameters: [{ $ref: "#/components/parameters/ChannelAccountId" }]
      responses:
        "200":
          description: Policies.
          content:
            application/json:
              schema:
                type: object
                required: [object, data]
                properties:
                  object: { type: string, const: list }
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Policy" }
        "404": { $ref: "#/components/responses/NotFound" }

  /products:
    get:
      tags: [Products]
      operationId: listProducts
      summary: List products
      description: >
        Stable ordering (`updated_at` desc, `id` tiebreak). Use
        `updated_after` for incremental sync crawls.
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - name: status
          in: query
          schema: { $ref: "#/components/schemas/ProductStatus" }
        - name: sku
          in: query
          description: Exact match on any variant SKU.
          schema: { type: string }
        - name: updated_after
          in: query
          schema: { type: string, format: date-time }
      responses:
        "200":
          description: Paginated products.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Page"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Product" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      tags: [Products]
      operationId: createProduct
      summary: Create a product
      description: >
        Creates the product locally and returns it immediately. Products may
        be created **without images** — they stay local (draft) until you
        create listings for them. Publishing is a separate call
        (`POST /listings`).
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ProductCreate" }
      responses:
        "201":
          description: Product created (local write is synchronous).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Product" }
        "422": { $ref: "#/components/responses/ValidationError" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /products/batch:
    post:
      tags: [Products]
      operationId: batchUpsertProducts
      summary: Batch upsert products by SKU
      description: >
        Create or update up to 100 products in one call, matched by variant
        SKU. Items are validated upfront; per-item outcomes appear in the
        operation's `results[]` and the overall status may be `partial`.
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [items]
              properties:
                items:
                  type: array
                  minItems: 1
                  maxItems: 100
                  items: { $ref: "#/components/schemas/ProductCreate" }
      responses:
        "202":
          description: Batch accepted.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Operation" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /products/{id}:
    parameters: [{ $ref: "#/components/parameters/ProductId" }]
    get:
      tags: [Products]
      operationId: getProduct
      summary: Retrieve a product
      responses:
        "200":
          description: The product, with images, variants and a read-only summary of its listings.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Product" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Products]
      operationId: updateProduct
      summary: Update a product
      description: >
        Applies the local update synchronously and returns the full updated
        product with a `sync` block describing channel propagation.

        **Images** are updated through this endpoint: send the desired
        `images[]` array — elements with a known `id` are kept untouched
        (never re-uploaded, channel-side image ids preserved), elements
        without an `id` are added (hosted on Nembol's storage, whether you
        pass a URL or base64), and ids you omit are removed (and deleted from
        storage). Array order is display order. Omit the field entirely to
        leave images unchanged. New uploads count toward a daily quota of
        500 images per store (`429` `upload_quota_exceeded`).

        Concurrency is last-write-wins; pass `expected_updated_at` to fail
        with `412` if the product changed since you read it.
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ProductUpdate" }
      responses:
        "200":
          description: Updated product + channel sync state.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ProductWithSync" }
        "404": { $ref: "#/components/responses/NotFound" }
        "412":
          description: "`expected_updated_at` precondition failed — re-read and retry."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "422": { $ref: "#/components/responses/ValidationError" }
    delete:
      tags: [Products]
      operationId: deleteProduct
      summary: Delete a product
      description: >
        Archives the product (soft delete) and removes its hosted images.
        A product with LIVE listings cannot be deleted — delist each listing
        first (`DELETE /listings/{id}`), otherwise this returns `422`
        `product_still_listed`. This mirrors the platform invariant: Nembol
        never forgets a product that is still live on a channel.
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      responses:
        "202":
          description: Deletion accepted; channel delisting in progress.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Operation" }
        "404": { $ref: "#/components/responses/NotFound" }

  /products/{id}/variants/{variant_id}:
    patch:
      tags: [Inventory]
      operationId: updateVariant
      summary: Update one variant (quantity / price)
      parameters:
        - $ref: "#/components/parameters/ProductId"
        - name: variant_id
          in: path
          required: true
          schema: { type: string }
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              properties:
                quantity: { type: integer, minimum: 0 }
                price: { $ref: "#/components/schemas/Money" }
      responses:
        "200":
          description: Updated product + channel sync state.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ProductWithSync" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /inventory/batch:
    post:
      tags: [Inventory]
      operationId: batchInventory
      summary: Batch update stock and prices by SKU
      description: >
        Update quantity and/or price for up to 100 SKUs in one call. Unknown
        or ambiguous (duplicated) SKUs are rejected per-item in
        `rejected_items` without failing the batch; accepted items are
        tracked by the returned operation, whose overall status may be
        `partial`.
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [items]
              properties:
                items:
                  type: array
                  minItems: 1
                  maxItems: 100
                  items:
                    type: object
                    required: [sku]
                    minProperties: 2
                    properties:
                      sku: { type: string }
                      quantity: { type: integer, minimum: 0 }
                      price: { $ref: "#/components/schemas/Money" }
      responses:
        "202":
          description: Batch accepted.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Operation"
                  - type: object
                    properties:
                      rejected_items:
                        description: Items rejected at validation time.
                        type: array
                        items:
                          type: object
                          properties:
                            sku: { type: string }
                            error: { $ref: "#/components/schemas/ErrorBody" }
        "422": { $ref: "#/components/responses/ValidationError" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /files:
    post:
      tags: [Files]
      operationId: createFile
      summary: Upload a file
      description: >
        Upload an image binary when it isn't publicly hosted anywhere. The
        returned `url` can be used in product `images[]`. Max 10 MB;
        JPEG/PNG/WebP.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [base64]
              properties:
                base64:
                  type: string
                  description: Base64-encoded image (max 10 MB).
      responses:
        "201":
          description: File stored.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/File" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /listings:
    get:
      tags: [Listings]
      operationId: listListings
      summary: List listings
      description: >
        The query surface for everything channel-side. `?status=error`
        returns every failing listing; add `error_code` to target one
        specific failure (the exact filter your retry runs should use).
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - name: product_id
          in: query
          schema: { type: string }
        - name: channel_account_id
          in: query
          schema: { type: string }
        - name: channel
          in: query
          description: Base channel filter (e.g. `ebay` matches every eBay account).
          schema: { type: string }
        - name: status
          in: query
          schema: { $ref: "#/components/schemas/ListingStatus" }
        - name: error_code
          in: query
          description: Only listings whose `issues[]` contain this code.
          schema: { type: string }
        - name: updated_after
          in: query
          schema: { type: string, format: date-time }
      responses:
        "200":
          description: Paginated listings.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Page"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Listing" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      tags: [Listings]
      operationId: createListing
      summary: Publish a product to a channel account
      description: >
        Creates the listing and enqueues publication. Idempotent on
        `(product_id, channel_account_id)`: posting the same pair returns the
        existing listing instead of creating a duplicate. Fails upfront with
        `photo_required_for_channel` if the product has no images and the
        channel requires them.
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ListingCreate" }
      responses:
        "202":
          description: "Listing created, publication in progress (`status: ongoing`)."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Listing" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /listings/batch:
    post:
      tags: [Listings]
      operationId: batchCreateListings
      summary: Publish up to 100 listings in one call
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [items]
              properties:
                items:
                  type: array
                  minItems: 1
                  maxItems: 100
                  items: { $ref: "#/components/schemas/ListingCreate" }
      responses:
        "202":
          description: Batch accepted; per-item outcomes in the operation.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Operation" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /listings/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
    get:
      tags: [Listings]
      operationId: getListing
      summary: Retrieve a listing
      responses:
        "200":
          description: The listing.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Listing" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Listings]
      operationId: updateListing
      summary: Update listing overrides / resync
      description: >
        Change per-channel overrides or attributes; the listing is re-synced
        to the channel. Sending an empty body triggers a plain resync.
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ListingUpdate" }
      responses:
        "202":
          description: Update accepted, propagation in progress.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Listing" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }
    delete:
      tags: [Listings]
      operationId: deleteListing
      summary: Delist from the channel
      description: >
        Removes the listing from the channel. The record is **kept** with
        `status: delisted` (history preserved); publish again with
        `POST /listings`.
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      responses:
        "202":
          description: Delist accepted.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Listing" }
        "404": { $ref: "#/components/responses/NotFound" }

  /orders:
    get:
      tags: [Orders]
      operationId: listOrders
      summary: List orders
      description: >
        Orders ingested from all channels, one shape. Requires the
        `orders:read` scope (buyer personal data included — you are
        responsible for lawful processing).
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - name: channel_account_id
          in: query
          schema: { type: string }
        - name: channel
          in: query
          schema: { type: string }
        - name: status
          in: query
          schema: { $ref: "#/components/schemas/OrderStatus" }
        - name: created_after
          in: query
          schema: { type: string, format: date-time }
      responses:
        "200":
          description: Paginated orders.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Page"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Order" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /orders/{id}:
    get:
      tags: [Orders]
      operationId: getOrder
      summary: Retrieve an order
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: The order.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Order" }
        "404": { $ref: "#/components/responses/NotFound" }

  /operations:
    get:
      tags: [Operations]
      operationId: listOperations
      summary: List operations
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - name: status
          in: query
          schema: { $ref: "#/components/schemas/OperationStatus" }
        - name: created_after
          in: query
          schema: { type: string, format: date-time }
      responses:
        "200":
          description: Paginated operations.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Page"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Operation" }

  /operations/{id}:
    get:
      tags: [Operations]
      operationId: getOperation
      summary: Retrieve an operation
      description: >
        Prefer subscribing to the `operation.completed` webhook over polling.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: The operation with per-item, per-channel results.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Operation" }
        "404": { $ref: "#/components/responses/NotFound" }

  /webhook_endpoints:
    get:
      tags: [Webhooks]
      operationId: listWebhookEndpoints
      summary: List webhook endpoints
      responses:
        "200":
          description: Webhook endpoints.
          content:
            application/json:
              schema:
                type: object
                required: [object, data]
                properties:
                  object: { type: string, const: list }
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/WebhookEndpoint" }
    post:
      tags: [Webhooks]
      operationId: createWebhookEndpoint
      summary: Create a webhook endpoint
      description: >
        The signing `secret` is returned **only in this response** — store
        it. Deliveries are signed with
        `X-Nembol-Signature: t=<unix_ts>,v1=<hmac_sha256>` over
        `<t>.<raw_body>`. Delivery is at-least-once and unordered:
        deduplicate by `event.id` and tolerate unknown event types.
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url, events]
              properties:
                url:
                  type: string
                  format: uri
                  description: HTTPS only. Private/internal addresses are rejected.
                events:
                  type: array
                  minItems: 1
                  items: { $ref: "#/components/schemas/EventType" }
      responses:
        "201":
          description: Endpoint created (includes the secret, shown once).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookEndpointWithSecret" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /webhook_endpoints/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
    patch:
      tags: [Webhooks]
      operationId: updateWebhookEndpoint
      summary: Update a webhook endpoint
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                url: { type: string, format: uri }
                events:
                  type: array
                  items: { $ref: "#/components/schemas/EventType" }
                status:
                  type: string
                  enum: [enabled, disabled]
      responses:
        "200":
          description: Updated endpoint.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookEndpoint" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Webhooks]
      operationId: deleteWebhookEndpoint
      summary: Delete a webhook endpoint
      responses:
        "204": { description: Deleted. }
        "404": { $ref: "#/components/responses/NotFound" }

  /webhook_endpoints/{id}/rotate_secret:
    post:
      tags: [Webhooks]
      operationId: rotateWebhookSecret
      summary: Rotate the signing secret
      description: >
        Returns a new secret; the previous one keeps validating for 24 hours
        (deliveries carry both as multiple `v1=` entries during the window).
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: New secret (shown once).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookEndpointWithSecret" }
        "404": { $ref: "#/components/responses/NotFound" }

  /webhook_endpoints/{id}/test:
    post:
      tags: [Webhooks]
      operationId: sendTestEvent
      summary: Send a test event
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                type: { $ref: "#/components/schemas/EventType" }
      responses:
        "200":
          description: Test delivery attempted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  delivered: { type: boolean }
                  response_status: { type: integer }
        "404": { $ref: "#/components/responses/NotFound" }

  /events:
    get:
      tags: [Events]
      operationId: listEvents
      summary: List events
      description: >
        The durable event log (retained 30 days). Reconcile webhook
        deliveries, backfill after downtime, or skip webhooks entirely and
        poll this.
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - name: type
          in: query
          schema: { $ref: "#/components/schemas/EventType" }
        - name: created_after
          in: query
          schema: { type: string, format: date-time }
      responses:
        "200":
          description: Paginated events.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Page"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Event" }

  /events/{id}:
    get:
      tags: [Events]
      operationId: getEvent
      summary: Retrieve an event
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: The event.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Event" }
        "404": { $ref: "#/components/responses/NotFound" }

webhooks:
  order.created:
    post:
      summary: A new order was ingested from any channel
      description: Fired by pollers and channel webhooks alike. Respond 2xx within 10s.
      parameters:
        - $ref: "#/components/parameters/SignatureHeader"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/EventOrderCreated" }
      responses:
        "200": { description: Acknowledged. }
  order.status_changed:
    post:
      summary: An order's normalized status changed
      parameters: [{ $ref: "#/components/parameters/SignatureHeader" }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/EventOrderStatusChanged" }
      responses:
        "200": { description: Acknowledged. }
  product.updated:
    post:
      summary: A product's content changed (dashboard, API, or channel-side edit)
      parameters: [{ $ref: "#/components/parameters/SignatureHeader" }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/EventProductUpdated" }
      responses:
        "200": { description: Acknowledged. }
  product.deleted:
    post:
      summary: A product was deleted
      parameters: [{ $ref: "#/components/parameters/SignatureHeader" }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/EventProductDeleted" }
      responses:
        "200": { description: Acknowledged. }
  inventory.updated:
    post:
      summary: A variant's quantity changed (sale on any channel, edit, or API)
      parameters: [{ $ref: "#/components/parameters/SignatureHeader" }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/EventInventoryUpdated" }
      responses:
        "200": { description: Acknowledged. }
  listing.status_changed:
    post:
      summary: A listing changed status (ongoing → listed / error / delisted)
      parameters: [{ $ref: "#/components/parameters/SignatureHeader" }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/EventListingStatusChanged" }
      responses:
        "200": { description: Acknowledged. }
  operation.completed:
    post:
      summary: An asynchronous operation finished (succeeded, partial or failed)
      description: Subscribe to this instead of polling `GET /operations/{id}`.
      parameters: [{ $ref: "#/components/parameters/SignatureHeader" }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/EventOperationCompleted" }
      responses:
        "200": { description: Acknowledged. }

components:
  securitySchemes:
    apiKey:
      type: http
      scheme: bearer
      description: >
        `Authorization: Bearer nbl_live_...` (or `nbl_test_...` for test
        mode). Test-mode writes are validated and simulated but never
        persisted and never touch channels; simulated results are marked
        `simulated`.

  parameters:
    Limit:
      name: limit
      in: query
      schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
    Cursor:
      name: cursor
      in: query
      schema: { type: string }
    ProductId:
      name: id
      in: path
      required: true
      schema: { type: string }
    ChannelAccountId:
      name: id
      in: path
      required: true
      description: Channel account id from `GET /channel_accounts` (e.g. `chac_...`). Never construct it.
      schema: { type: string }
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: Unique client-generated key (max 255 chars), stored 24h.
      schema: { type: string, maxLength: 255 }
    SignatureHeader:
      name: X-Nembol-Signature
      in: header
      required: true
      description: >
        `t=<unix_ts>,v1=<hmac_sha256>` — HMAC-SHA256 with your endpoint
        secret over `<t>.<raw_body>`. Reject if `|now - t|` > 5 minutes.
      schema: { type: string }

  responses:
    Unauthorized:
      description: Missing, invalid or revoked API key.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Forbidden:
      description: The key lacks the required scope, or the plan does not include API access.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    NotFound:
      description: No such resource in this store.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    ValidationError:
      description: Invalid request; see `error.param`.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    RateLimited:
      description: Rate limit or daily write quota exceeded. Honor `Retry-After`.
      headers:
        Retry-After:
          schema: { type: integer }
          description: Seconds until capacity is available.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }

  schemas:
    Money:
      type: string
      pattern: '^\d+\.\d{2}$'
      description: Decimal string, 2 decimals.
      examples: ["19.90"]

    ErrorBody:
      type: object
      required: [type, code, message]
      properties:
        type:
          type: string
          enum: [invalid_request_error, authentication_error, rate_limit_error, api_error]
        code:
          type: string
          description: Stable machine-readable code (e.g. `product_not_found`, `photo_required_for_channel`, `idempotency_key_reused`).
        message:
          type: string
          description: Human-readable, English, actionable.
        param:
          type: string
          description: Field path the error refers to (e.g. `variants[2].price`).
        doc_url:
          type: string
          format: uri

    Error:
      type: object
      required: [error]
      properties:
        error: { $ref: "#/components/schemas/ErrorBody" }
        request_id: { type: string }

    Page:
      type: object
      required: [object, has_more]
      properties:
        object: { type: string, const: list }
        has_more: { type: boolean }
        next_cursor: { type: [string, "null"] }

    Store:
      type: object
      properties:
        id: { type: string, examples: [store_5f2a9c] }
        object: { type: string, const: store }
        name: { type: string, examples: [Acme Vintage] }
        currency:
          type: string
          description: ISO-4217 store base currency; all product prices are in this currency.
          examples: [USD]
        plan: { type: string, examples: [Enterprise] }
        mode: { type: string, enum: [live, test] }
        scopes:
          type: array
          items: { type: string }

    ChannelAccount:
      type: object
      properties:
        id:
          type: string
          description: Opaque and stable — survives nothing; if the account is disconnected and another is connected, that is a NEW id.
          examples: [chac_66a1f09b2]
        object: { type: string, const: channel_account }
        channel:
          type: string
          description: Base channel (e.g. `ebay`, `shopify`, `etsy`). New channels appear over time.
          examples: [ebay]
        label: { type: string, description: Display name of the channel., examples: [eBay] }
        shop_name:
          type: [string, "null"]
          description: The seller's account/shop name on the channel.
          examples: [carboagez]
        status: { type: string, enum: [connected, error, disconnected] }
        discovery:
          type: object
          description: Which discovery endpoints this channel supports.
          properties:
            categories: { type: boolean }
            attribute_schema: { type: boolean }
            policies: { type: boolean }

    Category:
      type: object
      properties:
        id: { type: string, examples: ["11450"] }
        object: { type: string, const: category }
        name: { type: string, examples: [Jackets & Coats] }
        path:
          type: string
          description: Human-readable breadcrumb.
          examples: ["Clothing > Men > Jackets & Coats"]
        leaf:
          type: boolean
          description: Only leaf categories can be used on listings.

    AttributeSchema:
      type: object
      properties:
        object: { type: string, const: attribute_schema }
        category_id: { type: string }
        schema:
          type: object
          description: >
            A standard JSON Schema for the listing `attributes` object in
            this category — required attributes, allowed values,
            cardinality. Nembol validates listing writes against it.
          additionalProperties: true

    Policy:
      type: object
      properties:
        id: { type: string, examples: [pol_6178493000] }
        object: { type: string, const: policy }
        type: { type: string, enum: [shipping, return, payment] }
        name: { type: string, examples: [Standard shipping EU] }

    File:
      type: object
      properties:
        id: { type: string, examples: [file_9dk2a] }
        object: { type: string, const: file }
        url: { type: string, format: uri }

    ImageInput:
      type: object
      description: >
        One of `url` or `base64` for new images; pass `id` alone to keep an
        existing image.
      properties:
        id: { type: string, description: Existing image id (keeps it untouched). }
        url: { type: string, format: uri, description: Publicly fetchable image URL. }
        base64: { type: string, description: Base64-encoded image (max 10 MB). }
        alt: { type: string }

    Image:
      type: object
      properties:
        id: { type: string, examples: [img_ph1x8] }
        object: { type: string, const: image }
        url: { type: string, format: uri }
        alt: { type: [string, "null"] }
        position: { type: integer, description: Zero-based; equals array index. }

    Variant:
      type: object
      properties:
        id: { type: string, examples: [var_66a1f1] }
        object: { type: string, const: variant }
        sku: { type: [string, "null"], examples: [VDJ-M-001] }
        title: { type: [string, "null"] }
        price: { $ref: "#/components/schemas/Money" }
        quantity: { type: integer }
        barcode: { type: [string, "null"] }
        options:
          type: object
          additionalProperties: { type: string }
          description: 'Option name → value (e.g. `{"Color": "Red", "Size": "M"}`).'

    VariantInput:
      type: object
      properties:
        sku: { type: string }
        title: { type: string }
        price: { $ref: "#/components/schemas/Money" }
        quantity: { type: integer, minimum: 0 }
        barcode: { type: string }
        options:
          type: object
          additionalProperties: { type: string }

    ProductStatus:
      type: string
      description: New values may be added; tolerate unknowns.
      enum: [ready, listed, archived, sold_out, error, ongoing]

    ListingStatus:
      type: string
      description: New values may be added; tolerate unknowns.
      enum: [listed, imported, ongoing, error, delisted]

    OrderStatus:
      type: string
      description: >
        Normalized cross-channel order status (one vocabulary for every
        channel; forward-only transitions). New values may be added.
      enum: [awaiting_payment, awaiting_shipment, shipped, canceled, refunded, partially_refunded]

    FulfillmentStatus:
      type: string
      description: Normalized fulfillment progress. New values may be added.
      enum: [unfulfilled, partial, shipped, delivered]

    OperationStatus:
      type: string
      enum: [pending, processing, succeeded, partial, failed]

    Product:
      type: object
      properties:
        id: { type: string, examples: [prod_66a1f09b2] }
        object: { type: string, const: product }
        title: { type: [string, "null"], examples: [Vintage denim jacket] }
        description: { type: [string, "null"] }
        brand: { type: [string, "null"] }
        tags:
          type: array
          items: { type: string }
        currency:
          type: string
          description: ISO-4217; echo of the store base currency.
        status: { $ref: "#/components/schemas/ProductStatus" }
        sync_status:
          type: string
          enum: [ok, pending, error]
          description: Rollup of the listings' statuses.
        images:
          type: array
          items: { $ref: "#/components/schemas/Image" }
        variants:
          type: array
          items: { $ref: "#/components/schemas/Variant" }
        listings:
          type: array
          description: Read-only summary; the canonical resource is `GET /listings?product_id=`.
          items: { $ref: "#/components/schemas/ListingSummary" }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    ProductCreate:
      type: object
      required: [title]
      properties:
        title: { type: string, maxLength: 255 }
        description: { type: string }
        brand: { type: string }
        tags:
          type: array
          items: { type: string }
        images:
          type: array
          maxItems: 20
          items: { $ref: "#/components/schemas/ImageInput" }
        variants:
          type: array
          maxItems: 100
          description: Omit for a single-variant product (top-level `price`/`quantity`/`sku` instead).
          items: { $ref: "#/components/schemas/VariantInput" }
        price: { $ref: "#/components/schemas/Money" }
        quantity: { type: integer, minimum: 0 }
        sku: { type: string }

    ProductUpdate:
      type: object
      minProperties: 1
      properties:
        title: { type: string, maxLength: 255 }
        description: { type: string }
        brand: { type: string }
        tags:
          type: array
          items: { type: string }
        images:
          type: array
          maxItems: 20
          description: Desired state, id-keyed diff — see endpoint description.
          items: { $ref: "#/components/schemas/ImageInput" }
        expected_updated_at:
          type: string
          format: date-time
          description: Optimistic-concurrency precondition; `412` on mismatch.

    SyncBlock:
      type: object
      description: Channel propagation state for the write just performed.
      properties:
        operation_id: { type: [string, "null"], examples: [op_8fd2c1] }
        channels:
          type: array
          items:
            type: object
            properties:
              channel_account_id: { type: string }
              status: { type: string, enum: [pending, ok, error, simulated] }

    ProductWithSync:
      allOf:
        - $ref: "#/components/schemas/Product"
        - type: object
          properties:
            sync: { $ref: "#/components/schemas/SyncBlock" }

    Issue:
      type: object
      description: A channel-side problem on this listing.
      properties:
        code: { type: string, examples: [photo_required_for_channel] }
        severity: { type: string, enum: [error, warning] }
        message: { type: string }
        attribute_names:
          type: array
          description: Listing attributes the issue refers to, when applicable.
          items: { type: string }

    ListingVariant:
      type: object
      properties:
        variant_id: { type: string, examples: [var_66a1f1] }
        sku: { type: [string, "null"] }
        id_on_channel: { type: [string, "null"] }
        status: { type: [string, "null"] }

    ListingSummary:
      type: object
      properties:
        id: { type: string, examples: [list_9ac2e] }
        channel_account_id: { type: string }
        channel: { type: string }
        status: { $ref: "#/components/schemas/ListingStatus" }
        url_on_channel: { type: [string, "null"], format: uri }

    Listing:
      type: object
      properties:
        id: { type: string, examples: [list_9ac2e] }
        object: { type: string, const: listing }
        product_id: { type: string }
        channel_account_id: { type: string }
        channel: { type: string, description: Base channel of the account (denormalized for convenience). }
        status: { $ref: "#/components/schemas/ListingStatus" }
        id_on_channel: { type: [string, "null"] }
        url_on_channel: { type: [string, "null"], format: uri }
        site:
          type: [string, "null"]
          description: eBay only — the marketplace site this listing lives on (e.g. `US`, `Italy`). Stamped at publish.
        overrides:
          type: object
          description: Per-channel content overrides; unset fields fall back to the product.
          properties:
            title: { type: [string, "null"] }
            description: { type: [string, "null"] }
            category_id: { type: [string, "null"] }
        attributes:
          type: object
          description: >
            Channel/category attributes (eBay item aspects, Etsy properties…).
            Open object in this spec; validated at write time against the
            category's `attribute_schema`.
          additionalProperties: true
        variants:
          type: array
          items: { $ref: "#/components/schemas/ListingVariant" }
        issues:
          type: array
          items: { $ref: "#/components/schemas/Issue" }
        operation_id:
          type: [string, "null"]
          description: The operation tracking the last write on this listing.
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    ListingCreate:
      type: object
      required: [product_id, channel_account_id]
      properties:
        product_id: { type: string }
        channel_account_id: { type: string }
        site:
          type: string
          description: eBay only — target marketplace site for NEW listings; defaults to the account's configured site.
        overrides:
          type: object
          description: Not available yet — sending a non-empty object returns 422. Arrives in a later release.
          properties:
            title: { type: string }
            description: { type: string }
            category_id: { type: string }
        attributes:
          type: object
          description: Not available yet — arrives with the attribute_schema discovery endpoints.
          additionalProperties: true

    ListingUpdate:
      type: object
      properties:
        overrides:
          type: object
          properties:
            title: { type: [string, "null"] }
            description: { type: [string, "null"] }
            category_id: { type: [string, "null"] }
        attributes:
          type: object
          additionalProperties: true

    OperationResult:
      type: object
      properties:
        channel_account_id: { type: [string, "null"] }
        listing_id: { type: [string, "null"] }
        sku: { type: [string, "null"], description: Present on batch inventory items. }
        status: { type: string, enum: [pending, succeeded, failed, simulated, channel_rate_limited] }
        error:
          type: [object, "null"]
          properties:
            code: { type: string }
            message: { type: string }

    Operation:
      type: object
      properties:
        id: { type: string, examples: [op_8fd2c1] }
        object: { type: string, const: operation }
        type:
          type: string
          description: e.g. `listing.create`, `product.delete`, `inventory.batch`. New values may be added.
        status: { $ref: "#/components/schemas/OperationStatus" }
        resource_type: { type: [string, "null"] }
        resource_id: { type: [string, "null"] }
        results:
          type: array
          items: { $ref: "#/components/schemas/OperationResult" }
        created_at: { type: string, format: date-time }
        completed_at: { type: [string, "null"], format: date-time }

    OrderLine:
      type: object
      properties:
        sku: { type: [string, "null"] }
        title: { type: [string, "null"] }
        quantity: { type: integer }
        price: { $ref: "#/components/schemas/Money" }
        product_id: { type: [string, "null"] }

    Order:
      type: object
      properties:
        id: { type: string, examples: [ord_1f83b] }
        object: { type: string, const: order }
        channel_account_id: { type: [string, "null"] }
        channel: { type: string }
        order_id_on_channel: { type: [string, "null"] }
        order_url: { type: [string, "null"], format: uri }
        status: { $ref: "#/components/schemas/OrderStatus" }
        fulfillment_status: { $ref: "#/components/schemas/FulfillmentStatus" }
        source_status:
          type: [string, "null"]
          description: The channel's own raw status string, verbatim — for debugging, never for logic.
        tracking:
          type: object
          properties:
            number: { type: [string, "null"] }
            carrier: { type: [string, "null"] }
            url: { type: [string, "null"], format: uri }
        currency: { type: [string, "null"], description: ISO-4217, per-order. }
        total: { $ref: "#/components/schemas/Money" }
        lines:
          type: array
          items: { $ref: "#/components/schemas/OrderLine" }
        buyer:
          type: object
          description: Buyer personal data — requires `orders:read`; process lawfully.
          properties:
            name: { type: [string, "null"] }
            email: { type: [string, "null"] }
            country: { type: [string, "null"] }
        created_at: { type: string, format: date-time }

    WebhookEndpoint:
      type: object
      properties:
        id: { type: string, examples: [whe_4b8s1] }
        object: { type: string, const: webhook_endpoint }
        url: { type: string, format: uri }
        events:
          type: array
          items: { $ref: "#/components/schemas/EventType" }
        status:
          type: string
          enum: [enabled, disabled, auto_disabled]
        created_at: { type: string, format: date-time }

    WebhookEndpointWithSecret:
      allOf:
        - $ref: "#/components/schemas/WebhookEndpoint"
        - type: object
          properties:
            secret:
              type: string
              description: Shown only in this response.

    EventType:
      type: string
      description: New event types may be added; tolerate unknowns.
      enum:
        - order.created
        - order.status_changed
        - product.updated
        - product.deleted
        - inventory.updated
        - listing.status_changed
        - operation.completed

    EventBase:
      type: object
      description: >
        Envelope delivered to webhooks and returned by `GET /events`.
        Deliveries are at-least-once and unordered — dedupe by `id`.
      required: [id, object, type, api_version, store_id, created_at, data]
      properties:
        id: { type: string, examples: [evt_2c91d] }
        object: { type: string, const: event }
        type: { $ref: "#/components/schemas/EventType" }
        api_version: { type: string, examples: ["2026-07-24"] }
        store_id: { type: string }
        created_at: { type: string, format: date-time }
        data:
          type: object
          properties:
            object:
              type: object
              description: Full snapshot of the resource at event time.

    Event:
      oneOf:
        - $ref: "#/components/schemas/EventOrderCreated"
        - $ref: "#/components/schemas/EventOrderStatusChanged"
        - $ref: "#/components/schemas/EventProductUpdated"
        - $ref: "#/components/schemas/EventProductDeleted"
        - $ref: "#/components/schemas/EventInventoryUpdated"
        - $ref: "#/components/schemas/EventListingStatusChanged"
        - $ref: "#/components/schemas/EventOperationCompleted"
      discriminator:
        propertyName: type
        mapping:
          order.created: "#/components/schemas/EventOrderCreated"
          order.status_changed: "#/components/schemas/EventOrderStatusChanged"
          product.updated: "#/components/schemas/EventProductUpdated"
          product.deleted: "#/components/schemas/EventProductDeleted"
          inventory.updated: "#/components/schemas/EventInventoryUpdated"
          listing.status_changed: "#/components/schemas/EventListingStatusChanged"
          operation.completed: "#/components/schemas/EventOperationCompleted"

    EventOrderCreated:
      allOf:
        - $ref: "#/components/schemas/EventBase"
        - type: object
          properties:
            type: { type: string, const: order.created }
            data:
              type: object
              properties:
                object: { $ref: "#/components/schemas/Order" }

    EventOrderStatusChanged:
      allOf:
        - $ref: "#/components/schemas/EventBase"
        - type: object
          properties:
            type: { type: string, const: order.status_changed }
            data:
              type: object
              properties:
                object: { $ref: "#/components/schemas/Order" }
                previous_status: { $ref: "#/components/schemas/OrderStatus" }

    EventProductUpdated:
      allOf:
        - $ref: "#/components/schemas/EventBase"
        - type: object
          properties:
            type: { type: string, const: product.updated }
            data:
              type: object
              properties:
                object: { $ref: "#/components/schemas/Product" }

    EventProductDeleted:
      allOf:
        - $ref: "#/components/schemas/EventBase"
        - type: object
          properties:
            type: { type: string, const: product.deleted }
            data:
              type: object
              properties:
                object:
                  type: object
                  properties:
                    id: { type: string }
                    object: { type: string, const: product }

    EventInventoryUpdated:
      allOf:
        - $ref: "#/components/schemas/EventBase"
        - type: object
          properties:
            type: { type: string, const: inventory.updated }
            data:
              type: object
              properties:
                object: { $ref: "#/components/schemas/Product" }
                source:
                  type: string
                  description: What caused the change.
                  enum: [order, channel_edit, dashboard, api]

    EventListingStatusChanged:
      allOf:
        - $ref: "#/components/schemas/EventBase"
        - type: object
          properties:
            type: { type: string, const: listing.status_changed }
            data:
              type: object
              properties:
                object: { $ref: "#/components/schemas/Listing" }
                previous_status: { $ref: "#/components/schemas/ListingStatus" }

    EventOperationCompleted:
      allOf:
        - $ref: "#/components/schemas/EventBase"
        - type: object
          properties:
            type: { type: string, const: operation.completed }
            data:
              type: object
              properties:
                object: { $ref: "#/components/schemas/Operation" }
