openapi: 3.1.0
info:
  title: ViperFlow REST API
  version: "1.0.0"
  summary: Read and write the data of one ViperFlow account (tenant) over HTTPS.
  description: |
    Server-to-server REST API for a single ViperFlow account. Authentication is a secret API key
    sent as a bearer token; the key decides the account, and no request parameter can switch it.
    Records of other accounts behave exactly like records that do not exist (404).

    Full prose documentation: API.md. Webhooks: WEBHOOKS.md.

    ## Region
    The API runs next to the database in `ap-northeast-1`. Send `x-region: ap-northeast-1` on
    every request (or append `?forceFunctionRegion=ap-northeast-1`, which every endpoint accepts
    and no endpoint treats as an unknown parameter) to avoid a cross-region database round trip.

    ## Environments
    A `vf_live_…` key commits its writes. A `vf_test_…` key reads the same live data and runs
    writes through the full validation and business logic inside a transaction that is always
    rolled back: the response is what the live call would have returned, plus a top-level
    `"livemode": false` and the header `X-ViperFlow-Dry-Run: true`. There is no sandbox dataset.

    ## Conventions
    * Single objects are returned as `{"data": {...}}`; lists as
      `{"data": [...], "pagination": {...}}` with keyset (cursor) pagination.
    * Every object carries an `object` discriminator. Objects never contain the account id,
      employee user ids, cost prices, internal payment notes, cheque branch/account numbers or
      share tokens.
    * Request bodies are strict: an unknown field is `422 validation_failed`.
    * Date-times are ISO-8601 UTC with milliseconds on output, and must carry a time-zone offset
      on input. In a query string send `+03:00` percent-encoded as `%2B03:00`.
    * Money is a JSON number with 2 decimals in ILS; percents are 0-100 with 2 decimals;
      `vat_rate` is a fraction with 4 decimals.
    * `Idempotency-Key` is honoured on every non-GET request and is bound to the method, path and
      body for 24 hours.
  contact:
    name: ViperFlow
    url: https://viper-flow.vercel.app
servers:
  - url: https://viperflow-api.srv2122641.hstgr.cloud/functions/v1/api
    description: Production
security:
  - bearerAuth: []
tags:
  - name: Meta
    description: Health and key identity.
  - name: Customers
    description: Customers and their contacts.
  - name: Orders
    description: Orders, order creation and the confirm/cancel transitions.
  - name: Products
    description: Catalog products and categories (read-only in v1).
  - name: Inventory
    description: Stock quantities and movement history (read-only in v1).
  - name: Payments
    description: Payments recorded against orders.
  - name: Documents
    description: Quotes, invoices and receipts (read-only in v1).
  - name: Suppliers
    description: Suppliers (read-only in v1).
  - name: Purchase orders
    description: Purchase orders (read-only in v1).
  - name: Warehouse
    description: Warehouse tasks (read-only in v1).
  - name: External references
    description: Links between ViperFlow records and ids in your own system.

paths:
  /v1/health:
    parameters:
      - $ref: "#/components/parameters/RegionHeader"
    get:
      tags: [Meta]
      operationId: getHealth
      summary: Service health
      description: No authentication, no scope, no rate limit. The only CORS-enabled endpoint.
      security: []
      responses:
        "200":
          description: The service is up.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Health" }

  /v1/me:
    parameters:
      - $ref: "#/components/parameters/RegionHeader"
    get:
      tags: [Meta]
      operationId: getMe
      summary: Identity of the calling API key
      description: Requires any valid key and no scope. The fastest way to check a key.
      responses:
        "200":
          description: The key and its account.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/ApiKeyIdentity" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/customers:
    parameters:
      - $ref: "#/components/parameters/RegionHeader"
    get:
      tags: [Customers]
      operationId: listCustomers
      summary: List customers
      description: "Scope: `customers:read`."
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Sort"
        - $ref: "#/components/parameters/CreatedSince"
        - $ref: "#/components/parameters/UpdatedSince"
        - $ref: "#/components/parameters/Ids"
        - $ref: "#/components/parameters/ExternalRefFilter"
        - name: search
          in: query
          required: false
          description: >-
            Case-insensitive substring match on business name, business id and contact
            name/phone/email. An all-digit value also matches `customer_number` exactly.
          schema: { type: string, maxLength: 200 }
      responses:
        "200":
          description: A page of customers.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Customer" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }
    post:
      tags: [Customers]
      operationId: createCustomer
      summary: Create a customer
      description: "Scope: `customers:write`. Idempotent with `Idempotency-Key`."
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
        - $ref: "#/components/parameters/IntegrationHeader"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CustomerCreate" }
            examples:
              withContact:
                summary: Customer with one contact and an external reference
                value:
                  business_name: "אולמי הגן בע\"מ"
                  business_id: "515123453"
                  address: { street: "הרצל 10", city: "חיפה" }
                  contacts:
                    - { name: "דנה כהן", phone: "050-1234567", email: "dana@example.co.il" }
                  external_ref: { integration: "woocommerce", external_id: "customer-881" }
      responses:
        "201":
          description: The created customer.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CustomerResponse" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "415": { $ref: "#/components/responses/UnsupportedMediaType" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /v1/customers/{id}:
    parameters:
      - $ref: "#/components/parameters/RegionHeader"
      - $ref: "#/components/parameters/ResourceId"
    get:
      tags: [Customers]
      operationId: getCustomer
      summary: Retrieve a customer
      description: "Scope: `customers:read`."
      responses:
        "200":
          description: The customer.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CustomerResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    patch:
      tags: [Customers]
      operationId: updateCustomer
      summary: Update a customer
      description: >-
        Scope `customers:write`. Send only the fields to change; `null` clears a nullable field.
        At least one field is required.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
        - $ref: "#/components/parameters/IntegrationHeader"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CustomerUpdate" }
      responses:
        "200":
          description: The updated customer.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CustomerResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/customers/{id}/contacts:
    parameters:
      - $ref: "#/components/parameters/RegionHeader"
      - $ref: "#/components/parameters/ResourceId"
    post:
      tags: [Customers]
      operationId: createCustomerContact
      summary: Add a contact to a customer
      description: >-
        Scope `customers:write`. Returns the whole customer. `is_primary: true` moves the primary
        flag; a customer that has no contact yet gets this one as primary either way.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
        - $ref: "#/components/parameters/IntegrationHeader"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ContactCreate" }
      responses:
        "201":
          description: The customer, including the new contact.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CustomerResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/customers/{id}/contacts/{contact_id}:
    parameters:
      - $ref: "#/components/parameters/RegionHeader"
      - $ref: "#/components/parameters/ResourceId"
      - name: contact_id
        in: path
        required: true
        description: Contact id. A contact of another customer is `404`.
        schema: { type: string, format: uuid }
    patch:
      tags: [Customers]
      operationId: updateCustomerContact
      summary: Update a contact
      description: >-
        Scope `customers:write`. `is_primary: false` on the current primary contact is
        `422 primary_required` - promote another contact instead.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
        - $ref: "#/components/parameters/IntegrationHeader"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ContactUpdate" }
      responses:
        "200":
          description: The customer, with the contact updated.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CustomerResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/orders:
    parameters:
      - $ref: "#/components/parameters/RegionHeader"
    get:
      tags: [Orders]
      operationId: listOrders
      summary: List orders
      description: "Scope: `orders:read`. List items omit `items`."
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Sort"
        - $ref: "#/components/parameters/CreatedSince"
        - $ref: "#/components/parameters/UpdatedSince"
        - $ref: "#/components/parameters/Ids"
        - $ref: "#/components/parameters/ExternalRefFilter"
        - name: status
          in: query
          required: false
          description: Comma-separated order statuses.
          schema:
            type: string
            example: "draft,confirmed"
        - name: payment_status
          in: query
          required: false
          description: Comma-separated payment statuses.
          schema:
            type: string
            example: "unpaid,partial"
        - name: customer_id
          in: query
          required: false
          schema: { type: string, format: uuid }
        - name: event_from
          in: query
          required: false
          description: Inclusive lower bound on the event calendar date.
          schema: { type: string, format: date }
        - name: event_to
          in: query
          required: false
          description: Inclusive upper bound on the event calendar date.
          schema: { type: string, format: date }
      responses:
        "200":
          description: A page of orders.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Order" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [Orders]
      operationId: createOrder
      summary: Create a draft order
      description: >-
        Scope `orders:write`. Idempotent with `Idempotency-Key`. Creates a `draft` order exactly
        as the web app's wizard does: the same prices, set/component expansion, option validation,
        logistics rows, totals, due date and payment schedule. Availability is not enforced (the
        web app only warns). A discount above the account's approval threshold is refused with
        `422 discount_requires_approval` before anything is written.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
        - $ref: "#/components/parameters/IntegrationHeader"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/OrderCreate" }
            examples:
              storeImport:
                summary: Import an online-store order by external references
                value:
                  customer_ref: { integration: "woocommerce", external_id: "customer-881" }
                  event_date: "2026-10-02"
                  delivery_date: "2026-10-01T14:00:00+03:00"
                  return_date: "2026-10-03T10:00:00+03:00"
                  event_location: "גן האירועים, חיפה"
                  items:
                    - product_ref: { integration: "woocommerce", external_id: "sku-TBL-180" }
                      quantity: 10
                  custom_items:
                    - { name: "עיצוב פרחים", quantity: 1, unit_price: 850 }
                  logistics: { truck: { quantity: 1 } }
                  payment: { type: "deposit", deposit_amount: 1000, due_date_option: "net30" }
                  external_ref: { integration: "woocommerce", external_id: "12345" }
      responses:
        "201":
          description: The created order, with `items`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OrderResponse" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "415": { $ref: "#/components/responses/UnsupportedMediaType" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /v1/orders/{id}:
    parameters:
      - $ref: "#/components/parameters/RegionHeader"
      - $ref: "#/components/parameters/ResourceId"
    get:
      tags: [Orders]
      operationId: getOrder
      summary: Retrieve an order
      description: "Scope: `orders:read`. Includes `items`."
      responses:
        "200":
          description: The order.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OrderResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    patch:
      tags: [Orders]
      operationId: updateOrder
      summary: Update order header fields
      description: >-
        Scope `orders:write`. Only `draft` and `confirmed` orders can be updated; any other status
        is `409 invalid_state_transition` with `details.status` and `details.allowed_statuses`.
        Items, prices and payment terms cannot be changed through the API.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
        - $ref: "#/components/parameters/IntegrationHeader"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/OrderUpdate" }
      responses:
        "200":
          description: The updated order.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OrderResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/orders/{id}/confirm:
    parameters:
      - $ref: "#/components/parameters/RegionHeader"
      - $ref: "#/components/parameters/ResourceId"
    post:
      tags: [Orders]
      operationId: confirmOrder
      summary: Confirm a draft order
      description: >-
        Scope `orders:write`. Takes no body. `draft` becomes `confirmed` through the same database
        transition the web app uses, so warehouse picking tasks, field tasks and activity entries
        are created by the usual rules. Confirming an already confirmed order returns it
        unchanged; any other status is `409 invalid_state_transition` (the message comes from the
        database and is in Hebrew).
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
        - $ref: "#/components/parameters/IntegrationHeader"
      responses:
        "200":
          description: The confirmed order.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OrderResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/orders/{id}/cancel:
    parameters:
      - $ref: "#/components/parameters/RegionHeader"
      - $ref: "#/components/parameters/ResourceId"
    post:
      tags: [Orders]
      operationId: cancelOrder
      summary: Cancel an order
      description: >-
        Scope `orders:write`. Takes no body. `draft` or `confirmed` becomes `cancelled` and the
        order's open warehouse and field tasks are removed by the usual rules. Cancelling an
        already cancelled order returns it unchanged; `picked`, `delivered` and `returned` orders
        are `409 invalid_state_transition`.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
        - $ref: "#/components/parameters/IntegrationHeader"
      responses:
        "200":
          description: The cancelled order.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OrderResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/products:
    parameters:
      - $ref: "#/components/parameters/RegionHeader"
    get:
      tags: [Products]
      operationId: listProducts
      summary: List products
      description: "Scope: `products:read`. List items omit `components` and `variant_prices`."
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Sort"
        - $ref: "#/components/parameters/CreatedSince"
        - $ref: "#/components/parameters/UpdatedSince"
        - $ref: "#/components/parameters/Ids"
        - $ref: "#/components/parameters/ExternalRefFilter"
        - name: search
          in: query
          required: false
          description: Case-insensitive substring match on the product name only.
          schema: { type: string, maxLength: 200 }
        - name: category_id
          in: query
          required: false
          description: Exact category; sub-categories are not included.
          schema: { type: string, format: uuid }
        - name: is_active
          in: query
          required: false
          description: Omitted returns both active and inactive products.
          schema: { type: boolean }
      responses:
        "200":
          description: A page of products.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Product" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/products/{id}:
    parameters:
      - $ref: "#/components/parameters/RegionHeader"
      - $ref: "#/components/parameters/ResourceId"
    get:
      tags: [Products]
      operationId: getProduct
      summary: Retrieve a product
      description: "Scope: `products:read`. Includes `components` and `variant_prices`."
      responses:
        "200":
          description: The product.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/Product" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/categories:
    parameters:
      - $ref: "#/components/parameters/RegionHeader"
    get:
      tags: [Products]
      operationId: listCategories
      summary: List categories
      description: >-
        Scope `products:read`. Takes no parameters and returns every category in one page ordered
        by `sort_order`, then name: `pagination.limit` is 1000, `has_more` is `false` and
        `next_cursor` is `null`. An account with more than 1000 categories gets the first 1000 and
        `has_more: true` without a cursor.
      responses:
        "200":
          description: All categories.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Category" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/inventory:
    parameters:
      - $ref: "#/components/parameters/RegionHeader"
    get:
      tags: [Inventory]
      operationId: listInventory
      summary: List inventory items
      description: >-
        Scope `inventory:read`. One item per product. Quantities do not include date-window
        reservations (`reservations_included: false`). Sorting and `updated_since` use the
        product's timestamps.
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Sort"
        - $ref: "#/components/parameters/UpdatedSince"
        - name: product_ids
          in: query
          required: false
          description: Comma-separated product ids, at most 100.
          schema: { type: string }
        - name: is_active
          in: query
          required: false
          schema: { type: boolean }
        - name: low_stock
          in: query
          required: false
          description: Only items whose on-hand quantity is at or below the low-stock threshold.
          schema: { type: boolean }
        - name: include_untracked
          in: query
          required: false
          description: Include products that are not inventory-tracked.
          schema: { type: boolean, default: false }
      responses:
        "200":
          description: A page of inventory items.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/InventoryItem" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/inventory/movements:
    parameters:
      - $ref: "#/components/parameters/RegionHeader"
    get:
      tags: [Inventory]
      operationId: listInventoryMovements
      summary: List inventory movements
      description: >-
        Scope `inventory:read`. Append-only history; sync it with `created_since` and
        `sort=created_at`.
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - name: sort
          in: query
          required: false
          description: This list has no `updated_at`, so only the creation sorts are available.
          schema:
            type: string
            enum: ["-created_at", "created_at"]
            default: "-created_at"
        - $ref: "#/components/parameters/CreatedSince"
        - name: product_id
          in: query
          required: false
          schema: { type: string, format: uuid }
        - name: change_type
          in: query
          required: false
          description: Comma-separated movement kinds.
          schema:
            type: string
            example: "add,remove"
      responses:
        "200":
          description: A page of movements.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/InventoryMovement" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/payments:
    parameters:
      - $ref: "#/components/parameters/RegionHeader"
    get:
      tags: [Payments]
      operationId: listPayments
      summary: List payments
      description: "Scope: `payments:read` (a `payments:write` key also needs it to read)."
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Sort"
        - $ref: "#/components/parameters/CreatedSince"
        - $ref: "#/components/parameters/UpdatedSince"
        - $ref: "#/components/parameters/Ids"
        - $ref: "#/components/parameters/ExternalRefFilter"
        - name: order_id
          in: query
          required: false
          schema: { type: string, format: uuid }
        - name: customer_id
          in: query
          required: false
          schema: { type: string, format: uuid }
        - name: status
          in: query
          required: false
          description: Comma-separated payment statuses.
          schema:
            type: string
            example: "pending,cleared"
        - name: paid_from
          in: query
          required: false
          description: >-
            Inclusive lower bound on `paid_at`. A `YYYY-MM-DD` value means the whole Asia/Jerusalem
            day; an ISO-8601 date-time with an offset is also accepted.
          schema: { type: string }
        - name: paid_to
          in: query
          required: false
          description: Inclusive upper bound on `paid_at`, same formats as `paid_from`.
          schema: { type: string }
      responses:
        "200":
          description: A page of payments.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Payment" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [Payments]
      operationId: createPayment
      summary: Record a payment
      description: >-
        Scope `payments:write`. Idempotent with `Idempotency-Key`. Behaves like the web app's
        payment dialog: the order's paid and pending amounts and its payment status are
        recalculated. An order the key cannot see is `422` with `order_id: not_found`.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
        - $ref: "#/components/parameters/IntegrationHeader"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PaymentCreate" }
            examples:
              bankTransfer:
                summary: Cleared bank transfer
                value:
                  order_id: "a3e1b2c4-5d6f-4a7b-8c9d-0e1f2a3b4c5d"
                  amount: 1000
                  method: "bank_transfer"
                  reference: "העברה 7781"
              cheque:
                summary: Pending cheque
                value:
                  order_id: "a3e1b2c4-5d6f-4a7b-8c9d-0e1f2a3b4c5d"
                  amount: 1000
                  method: "check"
                  check: { number: "000123", bank: "לאומי", due_date: "2026-10-01" }
      responses:
        "201":
          description: The recorded payment.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/Payment" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "415": { $ref: "#/components/responses/UnsupportedMediaType" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/payments/{id}:
    parameters:
      - $ref: "#/components/parameters/RegionHeader"
      - $ref: "#/components/parameters/ResourceId"
    get:
      tags: [Payments]
      operationId: getPayment
      summary: Retrieve a payment
      description: "Scope: `payments:read`."
      responses:
        "200":
          description: The payment.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/Payment" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/documents:
    parameters:
      - $ref: "#/components/parameters/RegionHeader"
    get:
      tags: [Documents]
      operationId: listDocuments
      summary: List documents
      description: "Scope: `documents:read`. List items omit `lines` and `payments`."
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Sort"
        - $ref: "#/components/parameters/CreatedSince"
        - $ref: "#/components/parameters/UpdatedSince"
        - $ref: "#/components/parameters/Ids"
        - $ref: "#/components/parameters/ExternalRefFilter"
        - name: doc_type
          in: query
          required: false
          description: Comma-separated document types.
          schema:
            type: string
            example: "tax_invoice,tax_invoice_receipt"
        - name: status
          in: query
          required: false
          description: Comma-separated document statuses.
          schema:
            type: string
            example: "issued"
        - name: customer_id
          in: query
          required: false
          schema: { type: string, format: uuid }
        - name: order_id
          in: query
          required: false
          schema: { type: string, format: uuid }
        - name: date_from
          in: query
          required: false
          description: Inclusive lower bound on `document_date`.
          schema: { type: string, format: date }
        - name: date_to
          in: query
          required: false
          description: Inclusive upper bound on `document_date`.
          schema: { type: string, format: date }
      responses:
        "200":
          description: A page of documents.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Document" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/documents/{id}:
    parameters:
      - $ref: "#/components/parameters/RegionHeader"
      - $ref: "#/components/parameters/ResourceId"
    get:
      tags: [Documents]
      operationId: getDocument
      summary: Retrieve a document
      description: "Scope: `documents:read`. Includes `lines` and `payments`."
      responses:
        "200":
          description: The document.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/Document" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/suppliers:
    parameters:
      - $ref: "#/components/parameters/RegionHeader"
    get:
      tags: [Suppliers]
      operationId: listSuppliers
      summary: List suppliers
      description: "Scope: `suppliers:read`."
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Sort"
        - $ref: "#/components/parameters/CreatedSince"
        - $ref: "#/components/parameters/UpdatedSince"
        - $ref: "#/components/parameters/Ids"
        - $ref: "#/components/parameters/ExternalRefFilter"
        - name: search
          in: query
          required: false
          description: >-
            Case-insensitive substring match on name, business id, email, phone and contact
            name/phone/email.
          schema: { type: string, maxLength: 200 }
        - name: is_active
          in: query
          required: false
          schema: { type: boolean }
      responses:
        "200":
          description: A page of suppliers.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Supplier" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/suppliers/{id}:
    parameters:
      - $ref: "#/components/parameters/RegionHeader"
      - $ref: "#/components/parameters/ResourceId"
    get:
      tags: [Suppliers]
      operationId: getSupplier
      summary: Retrieve a supplier
      description: "Scope: `suppliers:read`."
      responses:
        "200":
          description: The supplier.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/Supplier" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/purchase-orders:
    parameters:
      - $ref: "#/components/parameters/RegionHeader"
    get:
      tags: [Purchase orders]
      operationId: listPurchaseOrders
      summary: List purchase orders
      description: "Scope: `purchase_orders:read`. List items omit `lines`."
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Sort"
        - $ref: "#/components/parameters/CreatedSince"
        - $ref: "#/components/parameters/UpdatedSince"
        - $ref: "#/components/parameters/Ids"
        - $ref: "#/components/parameters/ExternalRefFilter"
        - name: status
          in: query
          required: false
          description: Comma-separated purchase-order statuses.
          schema:
            type: string
            example: "sent,confirmed"
        - name: supplier_id
          in: query
          required: false
          schema: { type: string, format: uuid }
        - name: date_from
          in: query
          required: false
          description: Inclusive lower bound on `order_date`.
          schema: { type: string, format: date }
        - name: date_to
          in: query
          required: false
          description: Inclusive upper bound on `order_date`.
          schema: { type: string, format: date }
      responses:
        "200":
          description: A page of purchase orders.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/PurchaseOrder" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/purchase-orders/{id}:
    parameters:
      - $ref: "#/components/parameters/RegionHeader"
      - $ref: "#/components/parameters/ResourceId"
    get:
      tags: [Purchase orders]
      operationId: getPurchaseOrder
      summary: Retrieve a purchase order
      description: "Scope: `purchase_orders:read`. Includes `lines`."
      responses:
        "200":
          description: The purchase order.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/PurchaseOrder" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/warehouse/tasks:
    parameters:
      - $ref: "#/components/parameters/RegionHeader"
    get:
      tags: [Warehouse]
      operationId: listWarehouseTasks
      summary: List warehouse tasks
      description: "Scope: `warehouse:read`. This list has no `external_ref` filter."
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Sort"
        - $ref: "#/components/parameters/CreatedSince"
        - $ref: "#/components/parameters/UpdatedSince"
        - $ref: "#/components/parameters/Ids"
        - name: status
          in: query
          required: false
          description: Comma-separated task statuses.
          schema:
            type: string
            example: "scheduled,in_progress"
        - name: type
          in: query
          required: false
          description: Comma-separated task types.
          schema:
            type: string
            example: "picking,return"
        - name: order_id
          in: query
          required: false
          schema: { type: string, format: uuid }
        - name: scheduled_from
          in: query
          required: false
          description: Inclusive lower bound on `scheduled_date`.
          schema: { type: string, format: date }
        - name: scheduled_to
          in: query
          required: false
          description: Inclusive upper bound on `scheduled_date`.
          schema: { type: string, format: date }
      responses:
        "200":
          description: A page of warehouse tasks.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/WarehouseTask" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/warehouse/tasks/{id}:
    parameters:
      - $ref: "#/components/parameters/RegionHeader"
      - $ref: "#/components/parameters/ResourceId"
    get:
      tags: [Warehouse]
      operationId: getWarehouseTask
      summary: Retrieve a warehouse task
      description: "Scope: `warehouse:read`."
      responses:
        "200":
          description: The warehouse task.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/WarehouseTask" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/external-refs:
    parameters:
      - $ref: "#/components/parameters/RegionHeader"
    get:
      tags: [External references]
      operationId: listExternalRefs
      summary: Look up external references
      description: >-
        Requires the **read** scope of the `entity_type` being queried (for example `orders:read`
        for `entity_type=order`). Exactly one of `external_id` and `entity_id` must be given. This
        endpoint does not paginate: `pagination.limit` is 100, `has_more` is `false`. External ids
        that contain a comma cannot be looked up here - use the `external_ref` filter of the
        entity's own list instead.
      parameters:
        - name: integration
          in: query
          required: true
          description: Your integration slug.
          schema: { type: string, pattern: "^[a-z0-9][a-z0-9_.-]{0,63}$" }
        - name: entity_type
          in: query
          required: true
          schema: { $ref: "#/components/schemas/ExternalRefEntityType" }
        - name: external_id
          in: query
          required: false
          description: Comma-separated external ids, at most 100, each at most 255 characters.
          schema: { type: string }
        - name: entity_id
          in: query
          required: false
          description: Comma-separated ViperFlow record ids, at most 100.
          schema: { type: string }
      responses:
        "200":
          description: The matching references, oldest first.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/ExternalRef" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
    put:
      tags: [External references]
      operationId: putExternalRef
      summary: Link a record to an external id
      description: >-
        Scope `external_refs:write`, and the key must be able to read the linked record. Upsert by
        **record**: the record's external id for that integration is replaced. If the external id
        already points at another existing record the request fails with
        `409 external_ref_conflict` and `details.existing_entity_id`.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
        - $ref: "#/components/parameters/IntegrationHeader"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ExternalRefPut" }
            examples:
              product:
                value:
                  integration: "woocommerce"
                  entity_type: "product"
                  entity_id: "c7b6a5d4-e3f2-4a1b-9c8d-7e6f5a4b3c2d"
                  external_id: "sku-TBL-180"
      responses:
        "200":
          description: The stored reference.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/ExternalRef" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "415": { $ref: "#/components/responses/UnsupportedMediaType" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/external-refs/{id}:
    parameters:
      - $ref: "#/components/parameters/RegionHeader"
      - name: id
        in: path
        required: true
        description: The reference's own id (not the record id).
        schema: { type: string, format: uuid }
    delete:
      tags: [External references]
      operationId: deleteExternalRef
      summary: Remove an external reference
      description: Scope `external_refs:write`. Removes the link only; the record is untouched.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
        - $ref: "#/components/parameters/IntegrationHeader"
      responses:
        "204":
          description: Removed.
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

webhooks:
  event:
    post:
      tags: [Meta]
      operationId: onViperFlowEvent
      summary: An event ViperFlow POSTs to your endpoint
      description: >-
        ViperFlow sends this request to every enabled webhook endpoint subscribed to the event's
        type. Verify `X-ViperFlow-Signature` against the raw body before parsing, then answer with
        any `2xx` within 10 seconds. Delivery is at least once and unordered - de-duplicate by
        `id`. See WEBHOOKS.md for the signature algorithm, the retry schedule and the full event
        catalog.
      security: []
      parameters:
        - name: X-ViperFlow-Event
          in: header
          required: true
          description: Event type, the same value as `type` in the body.
          schema: { type: string, example: "order.status_changed" }
        - name: X-ViperFlow-Event-Id
          in: header
          required: true
          description: "`evt_` + 32 hex characters; the de-duplication key."
          schema: { type: string, pattern: "^evt_[0-9a-f]{32}$" }
        - name: X-ViperFlow-Delivery
          in: header
          required: true
          description: "`whd_` + 32 hex characters; identical for every attempt."
          schema: { type: string, pattern: "^whd_[0-9a-f]{32}$" }
        - name: X-ViperFlow-Attempt
          in: header
          required: true
          description: 1-based attempt number (at most 7).
          schema: { type: integer, minimum: 1 }
        - name: X-ViperFlow-Timestamp
          in: header
          required: true
          description: Unix seconds; part of the signed payload. Tolerance is 300 seconds.
          schema: { type: string, pattern: "^[0-9]{1,12}$" }
        - name: X-ViperFlow-Signature
          in: header
          required: true
          description: >-
            One or more comma-separated `v1=<hex>` entries, each the HMAC-SHA256 of
            `"<timestamp>.<raw body>"` keyed by a signing secret. During a rotation grace period
            two entries are sent; accept the request if any of them matches.
          schema: { type: string, example: "v1=6f1a…c93b,v1=90bd…41ac" }
        - name: X-ViperFlow-Origin
          in: header
          required: true
          description: "`origin.source` of the event."
          schema:
            type: string
            enum: [api, app, inbound, system]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookEvent" }
      responses:
        "2XX":
          description: Accepted. ViperFlow records the delivery as succeeded.
        "4XX":
          description: >-
            Rejected on purpose. Not retried, except `408`, `425` and `429`.
        "5XX":
          description: Retried with exponential backoff (1 m, 5 m, 30 m, 2 h, 6 h, 24 h).

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: >-
        `Authorization: Bearer vf_live_<40 base62 characters>` (or `vf_test_…`). The key is a
        secret: it identifies the account and carries the scopes granted when it was created.
        Keys are created in the app under הגדרות › מפתחים and shown only once.

  parameters:
    RegionHeader:
      name: x-region
      in: header
      required: false
      description: >-
        Pins the request to the region the database runs in. Recommended on every request. The
        query parameter `forceFunctionRegion=ap-northeast-1` does the same and is accepted on
        every endpoint.
      schema:
        type: string
        enum: [ap-northeast-1]
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: >-
        1-255 printable ASCII characters, unique per logical operation (a UUID v4 is ideal). Bound
        to the method, path and canonical body for 24 hours: the same key with the same request
        replays the stored response with `Idempotent-Replayed: true`, and with a different request
        returns `422 idempotency_key_mismatch`. Ignored by test-mode keys.
      schema: { type: string, minLength: 1, maxLength: 255 }
    IntegrationHeader:
      name: X-ViperFlow-Integration
      in: header
      required: false
      description: >-
        Lowercase slug identifying your integration. Recorded as `origin.integration` on the
        webhook events your write causes, which lets you ignore your own echoes.
      schema: { type: string, pattern: "^[a-z0-9][a-z0-9_.-]{0,63}$", example: "woocommerce" }
    ResourceId:
      name: id
      in: path
      required: true
      description: >-
        Record id. A malformed id and a record of another account both answer `404 not_found`.
      schema: { type: string, format: uuid }
    Limit:
      name: limit
      in: query
      required: false
      description: Page size.
      schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
    Cursor:
      name: cursor
      in: query
      required: false
      description: >-
        Opaque cursor copied from `pagination.next_cursor`. A cursor belongs to the sort it was
        issued for; reusing it with another sort is `400 invalid_cursor`. Keep every other
        parameter identical while paging.
      schema: { type: string }
    Sort:
      name: sort
      in: query
      required: false
      description: "`-` means descending (newest first)."
      schema:
        type: string
        enum: ["-created_at", "created_at", "-updated_at", "updated_at"]
        default: "-created_at"
    CreatedSince:
      name: created_since
      in: query
      required: false
      description: >-
        Inclusive lower bound on `created_at`. ISO-8601 with a time-zone offset; a `+` must be
        percent-encoded as `%2B`.
      schema: { type: string, format: date-time, example: "2026-09-14T00:00:00Z" }
    UpdatedSince:
      name: updated_since
      in: query
      required: false
      description: >-
        Inclusive lower bound on `updated_at` - the parameter to poll with for incremental sync.
        ISO-8601 with a time-zone offset.
      schema: { type: string, format: date-time, example: "2026-09-14T00:00:00Z" }
    Ids:
      name: ids
      in: query
      required: false
      description: >-
        Comma-separated record ids, at most 100. Ids that do not exist or are not visible to the
        key are simply absent from the response.
      schema: { type: string }
    ExternalRefFilter:
      name: external_ref
      in: query
      required: false
      description: >-
        `<integration>:<external_id>`, split at the first colon. Returns the record linked to that
        external id.
      schema: { type: string, example: "woocommerce:12345" }

  responses:
    BadRequest:
      description: >-
        Unknown, duplicated or malformed query parameter or header (`invalid_request`), a body that
        is not valid JSON (`invalid_json`), or a cursor that does not match the sort
        (`invalid_cursor`).
      headers:
        X-Request-Id: { $ref: "#/components/headers/XRequestId" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
    Unauthorized:
      description: >-
        Missing, malformed, unknown, revoked or expired API key (`missing_api_key`,
        `invalid_api_key`, `api_key_revoked`, `api_key_expired`).
      headers:
        X-Request-Id: { $ref: "#/components/headers/XRequestId" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
    Forbidden:
      description: >-
        The key lacks the endpoint's scope (`insufficient_scope`, `details.required_scope`), the
        scope's module is not in the account's plan (`module_not_enabled`), or a database
        permission check refused the operation (`forbidden`).
      headers:
        X-Request-Id: { $ref: "#/components/headers/XRequestId" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
    NotFound:
      description: >-
        Unknown path, malformed path id, or a record that does not exist or is not visible to the
        key (`not_found`).
      headers:
        X-Request-Id: { $ref: "#/components/headers/XRequestId" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
    Conflict:
      description: >-
        `invalid_state_transition` (the record's status does not allow the operation),
        `external_ref_conflict` (`details.existing_entity_id`), or `conflict` (concurrent
        modification, duplicate, or an idempotent request still running - safe to retry).
      headers:
        X-Request-Id: { $ref: "#/components/headers/XRequestId" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
    PayloadTooLarge:
      description: The body exceeds 1 MB (`payload_too_large`, `details.max_bytes`).
      headers:
        X-Request-Id: { $ref: "#/components/headers/XRequestId" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
    UnsupportedMediaType:
      description: "The body was sent without `Content-Type: application/json`."
      headers:
        X-Request-Id: { $ref: "#/components/headers/XRequestId" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
    UnprocessableEntity:
      description: >-
        `validation_failed` (`details.fields`, `details.errors`), `business_rule_violation` (the
        message comes from the database and may be Hebrew), `idempotency_key_mismatch` or
        `discount_requires_approval` (`details.threshold_percent`).
      headers:
        X-Request-Id: { $ref: "#/components/headers/XRequestId" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
          examples:
            validationFailed:
              value:
                error:
                  code: "validation_failed"
                  message: "The request body failed validation."
                  details:
                    fields:
                      "items[0].quantity": "too_small"
                      delivery_date: "invalid_instant"
                    errors:
                      - path: "items[0].quantity"
                        code: "too_small"
                        message: "Must be at least 1."
                      - path: "delivery_date"
                        code: "invalid_instant"
                        message: "Must be an ISO-8601 date-time with a time zone offset."
                  request_id: "req_4f1c2a9b7e3d5c6a8b9e0f12"
    RateLimited:
      description: A rate limit was exceeded (`rate_limited`). Wait `Retry-After` seconds.
      headers:
        Retry-After:
          description: Seconds to wait.
          schema: { type: integer, minimum: 1 }
        X-RateLimit-Limit: { $ref: "#/components/headers/XRateLimitLimit" }
        X-RateLimit-Remaining: { $ref: "#/components/headers/XRateLimitRemaining" }
        X-RateLimit-Reset: { $ref: "#/components/headers/XRateLimitReset" }
        X-Request-Id: { $ref: "#/components/headers/XRequestId" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
    InternalError:
      description: >-
        Unexpected server error (`internal_error`). The full error is kept in ViperFlow's logs
        under `request_id`.
      headers:
        X-Request-Id: { $ref: "#/components/headers/XRequestId" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
    ServiceUnavailable:
      description: >-
        `timeout` (the operation exceeded the 15-second database time limit) or
        `service_unavailable` (temporary database or network failure). Retry after `Retry-After`.
      headers:
        Retry-After:
          description: Seconds to wait (5).
          schema: { type: integer, minimum: 1 }
        X-Request-Id: { $ref: "#/components/headers/XRequestId" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }

  headers:
    XRequestId:
      description: "`req_` + 24 hex characters. Also in `error.request_id` and the request log."
      schema: { type: string, pattern: "^req_[0-9a-f]{24}$" }
    XRateLimitLimit:
      description: Requests allowed in the current 60-second window of the most restrictive bucket.
      schema: { type: integer }
    XRateLimitRemaining:
      description: Requests left in that window.
      schema: { type: integer }
    XRateLimitReset:
      description: Unix seconds at which that window resets.
      schema: { type: integer }

  schemas:
    # ── Envelopes and errors ────────────────────────────────────────────────
    Pagination:
      type: object
      required: [limit, has_more, next_cursor]
      properties:
        limit:
          type: integer
          description: The page size that was applied.
        has_more:
          type: boolean
          description: Whether another page exists.
        next_cursor:
          type: [string, "null"]
          description: Cursor for the next page; `null` on the last page.
      examples:
        - { limit: 25, has_more: true, next_cursor: "eyJzIjoiLWNyZWF0ZWRfYXQiLCJ2IjoiMjAyNi0wOS0xNVQwODoxMDowMC4wMDAwMDBaIiwiaWQiOiJhM2UxYjJjNC01ZDZmLTRhN2ItOGM5ZC0wZTFmMmEzYjRjNWQifQ" }
    ListEnvelope:
      type: object
      required: [data, pagination]
      properties:
        data:
          type: array
          items: { type: object }
        pagination: { $ref: "#/components/schemas/Pagination" }
        livemode:
          type: boolean
          description: Present and `false` only on test-mode writes.
    Error:
      type: object
      required: [code, message]
      properties:
        code:
          type: string
          description: Stable machine-readable code. Branch on this, never on `message`.
          enum:
            - missing_api_key
            - invalid_api_key
            - api_key_revoked
            - api_key_expired
            - insufficient_scope
            - module_not_enabled
            - forbidden
            - not_found
            - method_not_allowed
            - invalid_request
            - invalid_json
            - invalid_cursor
            - unsupported_media_type
            - payload_too_large
            - validation_failed
            - business_rule_violation
            - invalid_state_transition
            - conflict
            - external_ref_conflict
            - idempotency_key_mismatch
            - discount_requires_approval
            - rate_limited
            - timeout
            - service_unavailable
            - internal_error
        message:
          type: string
          description: >-
            Human-readable English text that may change between releases. Business rules enforced
            inside the database pass the database's own message through, which is often Hebrew.
        details:
          type: object
          description: >-
            Code-specific context. Omitted when empty. Common keys: `parameter` and `reason`
            (query problems), `header`, `required_scope`, `allowed_methods`, `max_bytes`,
            `existing_entity_id`, `threshold_percent`, `status` and `allowed_statuses`, and
            `fields`/`errors` for `validation_failed`.
          additionalProperties: true
          properties:
            fields:
              type: object
              description: Field path to validation code, for quick lookups.
              additionalProperties: { type: string }
            errors:
              type: array
              description: The same validation problems with their messages.
              items: { $ref: "#/components/schemas/FieldError" }
        request_id:
          type: string
          description: Same value as the `X-Request-Id` response header.
          pattern: "^req_[0-9a-f]{24}$"
    ErrorResponse:
      type: object
      required: [error]
      properties:
        error: { $ref: "#/components/schemas/Error" }
    FieldError:
      type: object
      required: [path, code, message]
      properties:
        path:
          type: string
          description: "Field path, e.g. `items[0].quantity`; `$` for the body as a whole."
        code:
          type: string
          description: >-
            Stable validation code, e.g. `required`, `unknown_field`, `invalid_type`,
            `too_short`, `too_long`, `too_small`, `too_large`, `not_integer`,
            `too_many_decimals`, `invalid_enum`, `invalid_uuid`, `invalid_date`,
            `invalid_instant`, `invalid_email`, `invalid_format`, `invalid_characters`,
            `invalid_slug`, `invalid_tin`, `too_many_items`, `too_few_items`, `no_fields`,
            `conflicting_fields`, `not_allowed`, `not_found`, `multiple_primary`,
            `primary_required`, `before_delivery_date`, `after_return_date`, `empty_order`,
            `product_inactive`, `unknown_option`, `not_selectable`, `duplicate_choice_group`,
            `incomplete_selection`, `exceeds_total`, `amount_too_large`.
        message: { type: string }

    # ── Meta ───────────────────────────────────────────────────────────────
    Health:
      type: object
      required: [status, api_version, time]
      description: Not wrapped in `data`.
      properties:
        status: { type: string, enum: [ok] }
        api_version: { type: string, enum: [v1] }
        time: { type: string, format: date-time }
    ApiKeyIdentity:
      type: object
      required: [object, api_key, tenant, livemode]
      properties:
        object: { type: string, enum: [api_key_identity] }
        api_key:
          type: object
          required: [id, name, prefix, environment, scopes, rate_limit_per_minute, expires_at, created_at]
          properties:
            id: { type: string, format: uuid }
            name: { type: string }
            prefix:
              type: string
              description: "Display prefix, e.g. `vf_live_Ab3dE9xQ`. Never the whole key."
            environment: { type: string, enum: [live, test] }
            scopes:
              type: array
              items: { $ref: "#/components/schemas/Scope" }
            rate_limit_per_minute: { type: integer, minimum: 10, maximum: 1000 }
            expires_at: { type: [string, "null"], format: date-time }
            created_at: { type: string, format: date-time }
        tenant:
          type: object
          required: [id, name]
          properties:
            id: { type: string, format: uuid }
            name: { type: string }
        livemode:
          type: boolean
          description: "`false` for a `vf_test_` key."
    Scope:
      type: string
      description: An API key scope. Each scope grants a fixed set of ViperFlow permissions.
      enum:
        - customers:read
        - customers:write
        - orders:read
        - orders:write
        - products:read
        - inventory:read
        - payments:read
        - payments:write
        - documents:read
        - suppliers:read
        - purchase_orders:read
        - warehouse:read
        - external_refs:write

    # ── Shared value objects ───────────────────────────────────────────────
    ExternalRefLink:
      type: object
      description: A link to an id in an external system, as embedded in an object.
      required: [integration, external_id]
      properties:
        integration: { type: string, example: "woocommerce" }
        external_id: { type: string, example: "12345" }
    ExternalRefInput:
      type: object
      description: Attaches an external reference to the record being created, atomically.
      required: [integration, external_id]
      properties:
        integration: { type: string, pattern: "^[a-z0-9][a-z0-9_.-]{0,63}$" }
        external_id: { type: string, minLength: 1, maxLength: 255 }
    ExternalRefEntityType:
      type: string
      enum: [customer, order, product, payment, supplier, purchase_order, document]
    ExternalRef:
      type: object
      required: [id, object, integration, entity_type, entity_id, external_id, created_at, updated_at]
      properties:
        id: { type: string, format: uuid }
        object: { type: string, enum: [external_ref] }
        integration: { type: string }
        entity_type: { $ref: "#/components/schemas/ExternalRefEntityType" }
        entity_id: { type: string, format: uuid }
        external_id: { type: string }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    Address:
      type: object
      properties:
        street: { type: [string, "null"] }
        city: { type: [string, "null"] }
        zip: { type: [string, "null"] }
    Contact:
      type: object
      required: [id, name, role, phone, email, is_primary]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        role: { type: [string, "null"] }
        phone: { type: [string, "null"] }
        email: { type: [string, "null"] }
        is_primary: { type: boolean }
    Money:
      type: number
      description: ILS amount with at most 2 decimals.
    Percent:
      type: number
      minimum: 0
      maximum: 100
      description: Percentage 0-100 with at most 2 decimals.

    # ── Customer ───────────────────────────────────────────────────────────
    Customer:
      type: object
      required:
        [id, object, customer_number, display_number, business_name, business_id,
         default_discount_percent, address, notes, contacts, stats, external_refs,
         created_at, updated_at]
      properties:
        id: { type: string, format: uuid }
        object: { type: string, enum: [customer] }
        customer_number: { type: integer }
        display_number:
          type: string
          description: "`customer_number` zero-padded to 4 digits."
        business_name: { type: string }
        business_id:
          type: [string, "null"]
          description: Israeli company / dealer / ID number, normalised to 9 digits.
        default_discount_percent: { $ref: "#/components/schemas/Percent" }
        address: { $ref: "#/components/schemas/Address" }
        notes: { type: [string, "null"] }
        contacts:
          type: array
          description: Primary contact first, then by creation. Exactly one is primary.
          items: { $ref: "#/components/schemas/Contact" }
        stats:
          type: object
          description: >-
            Recalculated by ViperFlow; a recalculation moves `updated_at` but emits no webhook
            event.
          required: [total_orders, total_revenue, total_outstanding, last_order_at]
          properties:
            total_orders: { type: integer }
            total_revenue: { $ref: "#/components/schemas/Money" }
            total_outstanding: { $ref: "#/components/schemas/Money" }
            last_order_at: { type: [string, "null"], format: date-time }
        external_refs:
          type: array
          items: { $ref: "#/components/schemas/ExternalRefLink" }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    CustomerResponse:
      type: object
      required: [data]
      properties:
        data: { $ref: "#/components/schemas/Customer" }
        livemode:
          type: boolean
          description: Present and `false` only on test-mode writes.
    ContactCreate:
      type: object
      additionalProperties: false
      required: [name]
      properties:
        name: { type: string, minLength: 1, maxLength: 120 }
        role: { type: [string, "null"], maxLength: 120 }
        phone: { type: [string, "null"], maxLength: 40 }
        email: { type: [string, "null"], maxLength: 254, format: email }
        is_primary: { type: boolean }
    ContactUpdate:
      type: object
      additionalProperties: false
      description: "At least one field is required. `is_primary: false` on the primary is refused."
      properties:
        name: { type: string, minLength: 1, maxLength: 120 }
        role: { type: [string, "null"], maxLength: 120 }
        phone: { type: [string, "null"], maxLength: 40 }
        email: { type: [string, "null"], maxLength: 254, format: email }
        is_primary: { type: boolean }
    CustomerCreate:
      type: object
      additionalProperties: false
      required: [business_name]
      properties:
        business_name: { type: string, minLength: 1, maxLength: 200 }
        business_id:
          type: [string, "null"]
          description: >-
            Non-digits are stripped and the value is left-padded to 9 digits; the Israeli check
            digit must match (`invalid_tin`).
        default_discount_percent:
          allOf: [{ $ref: "#/components/schemas/Percent" }]
          default: 0
          description: Becomes the default order discount of this customer's new orders.
        address:
          type: [object, "null"]
          additionalProperties: false
          properties:
            street: { type: [string, "null"], maxLength: 200 }
            city: { type: [string, "null"], maxLength: 200 }
            zip: { type: [string, "null"], maxLength: 200 }
        notes: { type: [string, "null"], maxLength: 4000 }
        contacts:
          type: array
          maxItems: 20
          description: >-
            At most one may set `is_primary: true` (`multiple_primary`); when none does, the first
            contact becomes primary.
          items: { $ref: "#/components/schemas/ContactCreate" }
        external_ref: { $ref: "#/components/schemas/ExternalRefInput" }
    CustomerUpdate:
      type: object
      additionalProperties: false
      description: >-
        At least one field is required (`no_fields`). `address: null` clears street, city and zip;
        omitted inner keys of an address object keep their value.
      properties:
        business_name: { type: string, minLength: 1, maxLength: 200 }
        business_id: { type: [string, "null"] }
        default_discount_percent: { $ref: "#/components/schemas/Percent" }
        address:
          type: [object, "null"]
          additionalProperties: false
          properties:
            street: { type: [string, "null"], maxLength: 200 }
            city: { type: [string, "null"], maxLength: 200 }
            zip: { type: [string, "null"], maxLength: 200 }
        notes: { type: [string, "null"], maxLength: 4000 }

    # ── Order ──────────────────────────────────────────────────────────────
    OrderStatus:
      type: string
      description: >-
        Lifecycle `draft` → `confirmed` → `picked` → `delivered` → `returned`, plus `cancelled`
        from `draft` or `confirmed`. There is no `completed` status.
      enum: [draft, confirmed, picked, delivered, returned, cancelled]
    OrderPaymentStatus:
      type: string
      enum: [unpaid, partial, paid]
    OrderItemOption:
      type: object
      description: One selected option of a catalog item.
      required: [group_id, group_name, child_id, value]
      properties:
        group_id:
          type: [string, "null"]
          format: uuid
          description: Choice-group id; `null` for an optional component.
        group_name: { type: [string, "null"] }
        child_id:
          type: [string, "null"]
          format: uuid
          description: >-
            The selected product component; `null` when the option no longer exists in the
            catalog.
        value:
          type: [string, "null"]
          description: The option label as stored on the order.
    OrderItem:
      type: object
      required:
        [id, parent_item_id, line_type, is_component, component_type, product_id, name, quantity,
         unit_price, discount_percent, line_total, options, is_custom, is_inventory_tracked,
         notes, spare_quantity, picked_quantity, spare_picked_quantity, returned_quantity,
         return_status, missing_quantity, broken_quantity, sort_order]
      properties:
        id:
          type: string
          format: uuid
          description: >-
            **Not stable**: editing the order in the web app rewrites its lines with new ids.
            Replace an order's `items` wholesale rather than matching by item id.
        parent_item_id: { type: [string, "null"], format: uuid }
        line_type:
          type: string
          description: >-
            `product` for catalog and custom items, `worker` and `truck` for the logistics rows
            (every order has one of each, possibly with quantity 0). New values may be added.
          example: product
        is_component: { type: boolean }
        component_type:
          type: [string, "null"]
          enum: [mandatory, optional, choice_group, combination, null]
        product_id: { type: [string, "null"], format: uuid }
        name: { type: string }
        quantity: { type: integer }
        unit_price: { $ref: "#/components/schemas/Money" }
        discount_percent: { $ref: "#/components/schemas/Percent" }
        line_total: { $ref: "#/components/schemas/Money" }
        options:
          type: array
          items: { $ref: "#/components/schemas/OrderItemOption" }
        is_custom: { type: boolean }
        is_inventory_tracked: { type: boolean }
        notes: { type: [string, "null"] }
        spare_quantity: { type: integer }
        picked_quantity: { type: integer }
        spare_picked_quantity: { type: integer }
        returned_quantity: { type: integer }
        return_status: { type: [string, "null"] }
        missing_quantity: { type: integer }
        broken_quantity: { type: integer }
        sort_order: { type: integer }
    Order:
      type: object
      description: >-
        List responses omit `items`; `GET /v1/orders/{id}` and every order write include them.
      required:
        [id, object, order_number, status, customer_id, customer_name, contact_id, event,
         delivery_date, return_date, buffer_hours, event_confirmed, currency, totals, payment,
         notes, picking_completed_at, return_completed_at, spec_changed_after_pick, item_count,
         external_refs, created_at, updated_at]
      properties:
        id: { type: string, format: uuid }
        object: { type: string, enum: [order] }
        order_number: { type: string }
        status: { $ref: "#/components/schemas/OrderStatus" }
        customer_id: { type: string, format: uuid }
        customer_name: { type: [string, "null"] }
        contact_id: { type: [string, "null"], format: uuid }
        event:
          type: object
          required: [date, location]
          properties:
            date:
              type: [string, "null"]
              format: date
              description: Calendar day in Israel, independent of time zones.
            location: { type: [string, "null"] }
        delivery_date: { type: [string, "null"], format: date-time }
        return_date: { type: [string, "null"], format: date-time }
        buffer_hours:
          type: object
          description: "`null` means the account default applies."
          required: [before, after]
          properties:
            before: { type: [integer, "null"] }
            after: { type: [integer, "null"] }
        event_confirmed: { type: boolean }
        currency: { type: string, enum: [ILS] }
        totals:
          type: object
          description: >-
            Computed exactly as the web app does: line discounts, then the order discount on
            catalog and custom items (not on logistics), VAT at the account's rate, and the
            payable total rounded **down** to a whole shekel - the shaved agorot are part of
            `total_discount`.
          required:
            [subtotal, total_discount, taxable_amount, vat_amount, grand_total,
             order_discount_percent]
          properties:
            subtotal: { $ref: "#/components/schemas/Money" }
            total_discount: { $ref: "#/components/schemas/Money" }
            taxable_amount: { $ref: "#/components/schemas/Money" }
            vat_amount: { $ref: "#/components/schemas/Money" }
            grand_total: { $ref: "#/components/schemas/Money" }
            order_discount_percent: { $ref: "#/components/schemas/Percent" }
        payment:
          type: object
          required:
            [status, type, due_date_option, due_date, deposit_amount, paid_amount, pending_amount,
             balance_due]
          properties:
            status: { $ref: "#/components/schemas/OrderPaymentStatus" }
            type: { type: [string, "null"], enum: [full, net, deposit, null] }
            due_date_option: { type: [string, "null"], enum: [net, net30, net60, net90, specific, null] }
            due_date: { type: [string, "null"], format: date }
            deposit_amount: { $ref: "#/components/schemas/Money" }
            paid_amount:
              allOf: [{ $ref: "#/components/schemas/Money" }]
              description: Only `cleared` payments count as paid.
            pending_amount:
              allOf: [{ $ref: "#/components/schemas/Money" }]
              description: Pending cheques, for example.
            balance_due: { $ref: "#/components/schemas/Money" }
        notes: { type: [string, "null"] }
        picking_completed_at: { type: [string, "null"], format: date-time }
        return_completed_at: { type: [string, "null"], format: date-time }
        spec_changed_after_pick: { type: boolean }
        item_count:
          type: integer
          description: Every line, including components and zero-quantity logistics rows.
        items:
          type: array
          items: { $ref: "#/components/schemas/OrderItem" }
        external_refs:
          type: array
          items: { $ref: "#/components/schemas/ExternalRefLink" }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    OrderResponse:
      type: object
      required: [data]
      properties:
        data: { $ref: "#/components/schemas/Order" }
        livemode:
          type: boolean
          description: Present and `false` only on test-mode writes.
    OrderItemInput:
      type: object
      additionalProperties: false
      required: [quantity]
      description: Exactly one of `product_id` and `product_ref` must be given.
      properties:
        product_id: { type: string, format: uuid }
        product_ref: { $ref: "#/components/schemas/ExternalRefInput" }
        quantity: { type: integer, minimum: 1, maximum: 100000 }
        discount_percent:
          allOf: [{ $ref: "#/components/schemas/Percent" }]
          default: 0
        spare_quantity:
          type: integer
          minimum: 0
          maximum: 100000
          default: 0
          description: Extra units to pick as spares.
        notes: { type: [string, "null"], maxLength: 1000 }
        options:
          type: array
          maxItems: 50
          description: >-
            Product component ids: the chosen optional components and one option per choice group.
            When the product defines option combinations the chosen choice options must equal one
            defined combination (`incomplete_selection`). Other refusals: `unknown_option` (not a
            component of this product), `not_selectable` (a mandatory component),
            `duplicate_choice_group`.
          items: { type: string, format: uuid }
        unit_price:
          allOf: [{ $ref: "#/components/schemas/Money" }]
          maximum: 10000000
          description: >-
            Explicit unit price. Omitted means the catalog price computed like the wizard: the
            combination price when one is defined, else the base price plus the positive price
            deltas of the selected options; a combination-reserved component is priced from the
            reserved product when the combination has no custom price.
    CustomItemInput:
      type: object
      additionalProperties: false
      required: [name, quantity, unit_price]
      properties:
        name: { type: string, minLength: 1, maxLength: 300 }
        quantity: { type: integer, minimum: 1, maximum: 100000 }
        unit_price:
          allOf: [{ $ref: "#/components/schemas/Money" }]
          maximum: 10000000
        discount_percent:
          allOf: [{ $ref: "#/components/schemas/Percent" }]
          default: 0
        notes: { type: [string, "null"], maxLength: 1000 }
    LogisticsLineInput:
      type: object
      additionalProperties: false
      required: [quantity]
      properties:
        quantity: { type: integer, minimum: 0, maximum: 1000 }
        unit_price:
          allOf: [{ $ref: "#/components/schemas/Money" }]
          maximum: 10000000
          description: >-
            Omitted means the account default (1500 for a worker and 2400 for a truck when the
            account has not set its own).
    PaymentTermsInput:
      type: object
      additionalProperties: false
      required: [type]
      description: >-
        Omitted on create means full payment. Due dates are computed from today's date in Israel:
        `net` is the end of the current month and `netNN` that date plus NN days.
      properties:
        type: { type: string, enum: [full, net, deposit] }
        deposit_amount:
          allOf: [{ $ref: "#/components/schemas/Money" }]
          exclusiveMinimum: 0
          description: >-
            Required for `deposit` terms and not allowed otherwise. Must not exceed the order
            total (`exceeds_total`).
        due_date_option:
          type: string
          enum: [net, net30, net60, net90, specific]
          default: net30
          description: Not allowed for `full` terms.
        due_date:
          type: string
          format: date
          description: "Required for, and only allowed with, `due_date_option: specific`."
    OrderCreate:
      type: object
      additionalProperties: false
      required: [event_date, delivery_date, return_date]
      description: >-
        Exactly one of `customer_id` and `customer_ref` is required, and the order needs at least
        one item, custom item or logistics line with a quantity above 0 (`empty_order`).
      properties:
        customer_id: { type: string, format: uuid }
        customer_ref: { $ref: "#/components/schemas/ExternalRefInput" }
        contact_id:
          type: [string, "null"]
          format: uuid
          description: >-
            Must be a contact of the order's customer. **Omitted** means the customer's primary
            contact (as in the web app); `null` means no contact.
        event_date:
          type: string
          format: date
          description: Calendar day of the event.
        delivery_date:
          type: string
          format: date-time
          description: ISO-8601 instant with a time-zone offset; offset-less values are refused.
        return_date:
          type: string
          format: date-time
          description: >-
            ISO-8601 instant with a time-zone offset, not earlier than `delivery_date`
            (`before_delivery_date`).
        event_location: { type: [string, "null"], maxLength: 300 }
        notes: { type: [string, "null"], maxLength: 4000 }
        buffer_hours:
          type: object
          additionalProperties: false
          description: Omitted means the account defaults.
          properties:
            before: { type: integer, minimum: 0, maximum: 168 }
            after: { type: integer, minimum: 0, maximum: 168 }
        order_discount_percent:
          allOf: [{ $ref: "#/components/schemas/Percent" }]
          description: >-
            Omitted means the customer's `default_discount_percent`. A value above the account's
            approval threshold is refused with `422 discount_requires_approval`; API keys cannot
            approve high discounts.
        items:
          type: array
          maxItems: 200
          items: { $ref: "#/components/schemas/OrderItemInput" }
        custom_items:
          type: array
          maxItems: 50
          items: { $ref: "#/components/schemas/CustomItemInput" }
        logistics:
          type: object
          additionalProperties: false
          description: >-
            The canonical worker and truck rows, labelled `סידור ואיסוף` and `הובלה`.
          properties:
            worker: { $ref: "#/components/schemas/LogisticsLineInput" }
            truck: { $ref: "#/components/schemas/LogisticsLineInput" }
        payment: { $ref: "#/components/schemas/PaymentTermsInput" }
        external_ref: { $ref: "#/components/schemas/ExternalRefInput" }
    OrderUpdate:
      type: object
      additionalProperties: false
      description: >-
        Header fields only; at least one is required (`no_fields`). The resulting delivery/return
        window must keep `delivery_date <= return_date`.
      properties:
        notes: { type: [string, "null"], maxLength: 4000 }
        event_location: { type: [string, "null"], maxLength: 300 }
        contact_id: { type: [string, "null"], format: uuid }
        event_date: { type: string, format: date }
        delivery_date: { type: string, format: date-time }
        return_date: { type: string, format: date-time }
        buffer_hours:
          type: object
          additionalProperties: false
          description: "`null` for a value means the account default."
          properties:
            before: { type: [integer, "null"], minimum: 0, maximum: 168 }
            after: { type: [integer, "null"], minimum: 0, maximum: 168 }
        event_confirmed: { type: boolean }

    # ── Product / category ─────────────────────────────────────────────────
    ProductComponent:
      type: object
      required: [id, type, child_product_id, name, quantity, choice_group, price_delta, is_inventory_tracked, sort_order]
      properties:
        id: { type: string, format: uuid }
        type: { type: string, enum: [mandatory, optional, choice_group] }
        child_product_id: { type: [string, "null"], format: uuid }
        name: { type: [string, "null"] }
        quantity: { type: integer }
        choice_group:
          type: [object, "null"]
          description: Set only for choice-group options.
          properties:
            id: { type: [string, "null"], format: uuid }
            name: { type: [string, "null"] }
        price_delta:
          type: [number, "null"]
          description: The option's price override; `null` when it has none.
        is_inventory_tracked: { type: boolean }
        sort_order: { type: integer }
    ProductVariantPrice:
      type: object
      description: An option combination with its own price and image.
      required: [option_ids, price, image_url]
      properties:
        option_ids:
          type: array
          items: { type: string, format: uuid }
        price:
          type: [number, "null"]
          description: "`null` means the combination has no custom price."
        image_url: { type: [string, "null"] }
    Product:
      type: object
      description: List responses omit `components` and `variant_prices`.
      required:
        [id, object, name, description, category_id, base_price, currency, is_active,
         is_inventory_tracked, is_primary, is_new, show_in_warehouse, dimensions,
         default_image_url, low_stock_threshold, warehouse_entry_date, external_refs,
         created_at, updated_at]
      properties:
        id: { type: string, format: uuid }
        object: { type: string, enum: [product] }
        name: { type: string }
        description: { type: [string, "null"] }
        category_id: { type: [string, "null"], format: uuid }
        base_price: { $ref: "#/components/schemas/Money" }
        currency: { type: string, enum: [ILS] }
        is_active: { type: boolean }
        is_inventory_tracked: { type: boolean }
        is_primary: { type: boolean }
        is_new: { type: boolean }
        show_in_warehouse: { type: boolean }
        dimensions: { type: [string, "null"] }
        default_image_url: { type: [string, "null"] }
        low_stock_threshold: { type: [integer, "null"] }
        warehouse_entry_date: { type: [string, "null"], format: date }
        components:
          type: array
          items: { $ref: "#/components/schemas/ProductComponent" }
        variant_prices:
          type: array
          items: { $ref: "#/components/schemas/ProductVariantPrice" }
        external_refs:
          type: array
          items: { $ref: "#/components/schemas/ExternalRefLink" }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    Category:
      type: object
      required: [id, object, name, parent_id, sort_order, created_at]
      properties:
        id: { type: string, format: uuid }
        object: { type: string, enum: [category] }
        name: { type: string }
        parent_id: { type: [string, "null"], format: uuid }
        sort_order: { type: [integer, "null"] }
        created_at: { type: string, format: date-time }

    # ── Inventory ──────────────────────────────────────────────────────────
    InventoryItem:
      type: object
      description: One per product. It has no id of its own - use `product_id`.
      required:
        [object, product_id, name, is_active, is_inventory_tracked, total_quantity,
         in_maintenance_quantity, on_hand_quantity, reservations_included, low_stock_threshold,
         is_low_stock, updated_at]
      properties:
        object: { type: string, enum: [inventory_item] }
        product_id: { type: string, format: uuid }
        name: { type: string }
        is_active: { type: boolean }
        is_inventory_tracked: { type: boolean }
        total_quantity: { type: [integer, "null"] }
        in_maintenance_quantity: { type: [integer, "null"] }
        on_hand_quantity:
          type: [integer, "null"]
          description: >-
            `total_quantity` minus the in-maintenance quantity; `null` for a product that is not
            inventory-tracked.
        reservations_included:
          type: boolean
          const: false
          description: >-
            Always `false`: quantities booked by orders for specific dates are **not** subtracted.
            Date-window availability is not part of API v1.
        low_stock_threshold: { type: [integer, "null"] }
        is_low_stock:
          type: boolean
          description: Active, tracked, a threshold is set, and on-hand is at or below it.
        updated_at: { type: string, format: date-time }
    InventoryMovement:
      type: object
      required:
        [id, object, product_id, product_name, change_type, quantity_before, quantity_change,
         quantity_after, note, stock_count_id, created_at]
      properties:
        id: { type: string, format: uuid }
        object: { type: string, enum: [inventory_movement] }
        product_id: { type: [string, "null"], format: uuid }
        product_name: { type: [string, "null"] }
        change_type:
          type: string
          enum: [add, remove, maintenance_in, maintenance_out, count]
        quantity_before: { type: [integer, "null"] }
        quantity_change: { type: integer }
        quantity_after: { type: [integer, "null"] }
        note: { type: [string, "null"] }
        stock_count_id: { type: [string, "null"], format: uuid }
        created_at: { type: string, format: date-time }

    # ── Payment ────────────────────────────────────────────────────────────
    PaymentMethod:
      type: string
      enum: [cash, credit_card, bank_transfer, check, bit, offset, other]
    PaymentStatus:
      type: string
      description: Only `cleared` payments count as paid on the order.
      enum: [pending, deposited, cleared, bounced, cancelled]
    PaymentCheck:
      type: object
      description: >-
        Present only for `method: check`. Branch and account numbers are never returned.
      required: [number, bank, due_date]
      properties:
        number: { type: [string, "null"] }
        bank: { type: [string, "null"] }
        due_date: { type: [string, "null"], format: date }
    Payment:
      type: object
      required:
        [id, object, order_id, customer_id, direction, amount, currency, method, status, paid_at,
         reference, check, voided, voided_at, external_refs, created_at, updated_at]
      properties:
        id: { type: string, format: uuid }
        object: { type: string, enum: [payment] }
        order_id: { type: [string, "null"], format: uuid }
        customer_id: { type: [string, "null"], format: uuid }
        direction: { type: string, enum: [in, out] }
        amount: { $ref: "#/components/schemas/Money" }
        currency: { type: string, enum: [ILS] }
        method: { $ref: "#/components/schemas/PaymentMethod" }
        status: { $ref: "#/components/schemas/PaymentStatus" }
        paid_at: { type: [string, "null"], format: date-time }
        reference: { type: [string, "null"] }
        check:
          oneOf:
            - $ref: "#/components/schemas/PaymentCheck"
            - type: "null"
        voided: { type: boolean }
        voided_at: { type: [string, "null"], format: date-time }
        external_refs:
          type: array
          items: { $ref: "#/components/schemas/ExternalRefLink" }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    CheckInput:
      type: object
      additionalProperties: false
      required: [due_date]
      description: "Required for `method: check`, refused for every other method."
      properties:
        due_date: { type: string, format: date }
        number: { type: [string, "null"], maxLength: 50 }
        bank: { type: [string, "null"], maxLength: 100 }
        branch:
          type: [string, "null"]
          description: Non-digits are stripped; at most 10 digits. Never returned.
        account:
          type: [string, "null"]
          description: Non-digits are stripped; at most 15 digits. Never returned.
    PaymentCreate:
      type: object
      additionalProperties: false
      required: [order_id, amount, method]
      properties:
        order_id:
          type: string
          format: uuid
          description: "An order the key cannot see is `422` with `order_id: not_found`."
        amount:
          allOf: [{ $ref: "#/components/schemas/Money" }]
          exclusiveMinimum: 0
          maximum: 10000000
        direction:
          type: string
          enum: [in, out]
          default: in
        method: { $ref: "#/components/schemas/PaymentMethod" }
        paid_at:
          type: string
          format: date-time
          description: ISO-8601 instant with an offset. Omitted means now.
        reference: { type: [string, "null"], maxLength: 200 }
        notes:
          type: [string, "null"]
          maxLength: 2000
          description: Internal; never returned by the API.
        status:
          type: string
          enum: [pending, deposited, cleared]
          description: >-
            Cheques only, where it defaults to `pending` for an incoming payment and `cleared` for
            a refund. Any other method accepts only `cleared`, which is also its default.
        check: { $ref: "#/components/schemas/CheckInput" }
        external_ref: { $ref: "#/components/schemas/ExternalRefInput" }

    # ── Document ───────────────────────────────────────────────────────────
    DocumentType:
      type: string
      enum:
        - quote
        - order_confirmation
        - delivery_note
        - return_note
        - transaction_invoice
        - proforma
        - tax_invoice
        - tax_invoice_receipt
        - credit_invoice
        - receipt
    DocumentStatus:
      type: string
      enum: [draft, issued, cancelled]
    DocumentLine:
      type: object
      required: [id, position, description, sku, product_id, quantity, unit_price, discount_percent, vat_exempt, line_total]
      properties:
        id: { type: string, format: uuid }
        position: { type: integer }
        description: { type: [string, "null"] }
        sku: { type: [string, "null"] }
        product_id: { type: [string, "null"], format: uuid }
        quantity:
          type: number
          description: Up to 3 decimals.
        unit_price: { $ref: "#/components/schemas/Money" }
        discount_percent: { $ref: "#/components/schemas/Percent" }
        vat_exempt: { type: boolean }
        line_total: { $ref: "#/components/schemas/Money" }
    DocumentPayment:
      type: object
      required: [id, payment_id, method, amount, paid_at, reference, check]
      properties:
        id: { type: string, format: uuid }
        payment_id:
          type: [string, "null"]
          format: uuid
          description: The linked `/v1/payments` record, when there is one.
        method: { type: [string, "null"] }
        amount: { $ref: "#/components/schemas/Money" }
        paid_at: { type: [string, "null"], format: date-time }
        reference: { type: [string, "null"] }
        check:
          oneOf:
            - $ref: "#/components/schemas/PaymentCheck"
            - type: "null"
    Document:
      type: object
      description: >-
        Quotes, order confirmations, delivery and return notes, invoices and receipts. Read-only
        in v1; list responses omit `lines` and `payments`. Documents carry no `external_refs`
        field, but references to them can still be created and looked up through
        `/v1/external-refs`.
      required:
        [id, object, doc_type, status, doc_number, order_id, customer_id, references_document_id,
         customer, currency, totals, document_date, due_date, issued_at, notes, event_location,
         allocation, accepted_at, cancelled_at, created_at, updated_at]
      properties:
        id: { type: string, format: uuid }
        object: { type: string, enum: [document] }
        doc_type: { $ref: "#/components/schemas/DocumentType" }
        status: { $ref: "#/components/schemas/DocumentStatus" }
        doc_number: { type: [string, "null"] }
        order_id: { type: [string, "null"], format: uuid }
        customer_id: { type: [string, "null"], format: uuid }
        references_document_id: { type: [string, "null"], format: uuid }
        customer:
          type: object
          description: The customer details as they were snapshotted onto the document.
          required: [name, vat_number]
          properties:
            name: { type: [string, "null"] }
            vat_number: { type: [string, "null"] }
        currency: { type: string, enum: [ILS] }
        totals:
          type: object
          required: [subtotal, discount_total, taxable_amount, vat_rate, vat_amount, grand_total]
          properties:
            subtotal: { $ref: "#/components/schemas/Money" }
            discount_total: { $ref: "#/components/schemas/Money" }
            taxable_amount: { $ref: "#/components/schemas/Money" }
            vat_rate:
              type: number
              description: Fraction with 4 decimals, e.g. 0.18.
            vat_amount: { $ref: "#/components/schemas/Money" }
            grand_total: { $ref: "#/components/schemas/Money" }
        document_date: { type: [string, "null"], format: date }
        due_date: { type: [string, "null"], format: date }
        issued_at: { type: [string, "null"], format: date-time }
        notes: { type: [string, "null"] }
        event_location: { type: [string, "null"] }
        allocation:
          type: object
          description: >-
            Israel Tax Authority allocation-number state. A freshly issued document may still be
            `pending`.
          required: [status, number]
          properties:
            status: { type: [string, "null"] }
            number: { type: [string, "null"] }
        accepted_at:
          type: [string, "null"]
          format: date-time
          description: When the customer signed and accepted a quote.
        cancelled_at: { type: [string, "null"], format: date-time }
        lines:
          type: array
          items: { $ref: "#/components/schemas/DocumentLine" }
        payments:
          type: array
          items: { $ref: "#/components/schemas/DocumentPayment" }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    # ── Supplier / purchase order ──────────────────────────────────────────
    Supplier:
      type: object
      required:
        [id, object, name, business_id, email, phone, address, city, payment_terms, notes,
         is_active, contacts, external_refs, created_at, updated_at]
      properties:
        id: { type: string, format: uuid }
        object: { type: string, enum: [supplier] }
        name: { type: string }
        business_id: { type: [string, "null"] }
        email: { type: [string, "null"] }
        phone: { type: [string, "null"] }
        address: { type: [string, "null"] }
        city: { type: [string, "null"] }
        payment_terms: { type: [string, "null"] }
        notes: { type: [string, "null"] }
        is_active: { type: boolean }
        contacts:
          type: array
          items: { $ref: "#/components/schemas/Contact" }
        external_refs:
          type: array
          items: { $ref: "#/components/schemas/ExternalRefLink" }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    PurchaseOrderStatus:
      type: string
      enum: [draft, sent, confirmed, partially_received, received, cancelled]
    PurchaseOrderLine:
      type: object
      required:
        [id, position, description, product_id, category_id, unit, quantity, unit_price,
         discount_percent, vat_exempt, line_total, received_quantity]
      properties:
        id: { type: string, format: uuid }
        position: { type: integer }
        description: { type: [string, "null"] }
        product_id: { type: [string, "null"], format: uuid }
        category_id: { type: [string, "null"], format: uuid }
        unit: { type: [string, "null"] }
        quantity:
          type: number
          description: Up to 3 decimals.
        unit_price: { $ref: "#/components/schemas/Money" }
        discount_percent: { $ref: "#/components/schemas/Percent" }
        vat_exempt: { type: boolean }
        line_total: { $ref: "#/components/schemas/Money" }
        received_quantity:
          type: number
          description: Up to 3 decimals.
    PurchaseOrder:
      type: object
      description: List responses omit `lines`.
      required:
        [id, object, po_number, status, supplier_id, supplier_name, order_date, expected_date,
         currency, totals, supplier_notes, sent_at, confirmed_at, received_at, cancelled_at,
         external_refs, created_at, updated_at]
      properties:
        id: { type: string, format: uuid }
        object: { type: string, enum: [purchase_order] }
        po_number: { type: [string, "null"] }
        status: { $ref: "#/components/schemas/PurchaseOrderStatus" }
        supplier_id: { type: [string, "null"], format: uuid }
        supplier_name: { type: [string, "null"] }
        order_date: { type: [string, "null"], format: date }
        expected_date: { type: [string, "null"], format: date }
        currency: { type: string, enum: [ILS] }
        totals:
          type: object
          required: [subtotal, discount_total, vat_rate, vat_amount, grand_total]
          properties:
            subtotal: { $ref: "#/components/schemas/Money" }
            discount_total: { $ref: "#/components/schemas/Money" }
            vat_rate:
              type: number
              description: Fraction with 4 decimals.
            vat_amount: { $ref: "#/components/schemas/Money" }
            grand_total: { $ref: "#/components/schemas/Money" }
        supplier_notes: { type: [string, "null"] }
        sent_at: { type: [string, "null"], format: date-time }
        confirmed_at: { type: [string, "null"], format: date-time }
        received_at: { type: [string, "null"], format: date-time }
        cancelled_at: { type: [string, "null"], format: date-time }
        lines:
          type: array
          items: { $ref: "#/components/schemas/PurchaseOrderLine" }
        external_refs:
          type: array
          items: { $ref: "#/components/schemas/ExternalRefLink" }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    # ── Warehouse task ─────────────────────────────────────────────────────
    WarehouseTask:
      type: object
      required:
        [id, object, type, status, priority, order_id, customer_id, customer_name, title,
         description, scheduled_date, scheduled_start, estimated_duration_minutes,
         assigned_worker_name, actual_start, actual_end, total_quantity, completed_quantity,
         notes, created_at, updated_at]
      properties:
        id: { type: string, format: uuid }
        object: { type: string, enum: [warehouse_task] }
        type:
          type: string
          enum: [picking, return, inventory_count, maintenance, cleaning, transfer, other]
        status:
          type: string
          enum: [scheduled, ready, in_progress, paused, completed, delayed, cancelled]
        priority: { type: [string, "null"] }
        order_id: { type: [string, "null"], format: uuid }
        customer_id: { type: [string, "null"], format: uuid }
        customer_name: { type: [string, "null"] }
        title: { type: [string, "null"] }
        description: { type: [string, "null"] }
        scheduled_date: { type: [string, "null"], format: date }
        scheduled_start:
          type: [string, "null"]
          description: Time of day as `HH:MM`.
          pattern: "^[0-9]{2}:[0-9]{2}$"
        estimated_duration_minutes: { type: [integer, "null"] }
        assigned_worker_name: { type: [string, "null"] }
        actual_start: { type: [string, "null"], format: date-time }
        actual_end: { type: [string, "null"], format: date-time }
        total_quantity: { type: [integer, "null"] }
        completed_quantity: { type: [integer, "null"] }
        notes: { type: [string, "null"] }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    # ── Webhook event envelope ─────────────────────────────────────────────
    ExternalRefPut:
      type: object
      additionalProperties: false
      required: [integration, entity_type, entity_id, external_id]
      properties:
        integration: { type: string, pattern: "^[a-z0-9][a-z0-9_.-]{0,63}$" }
        entity_type: { $ref: "#/components/schemas/ExternalRefEntityType" }
        entity_id:
          type: string
          format: uuid
          description: >-
            The record to link. A record that does not exist or is not readable by the key is
            `422` with `entity_id: not_found`.
        external_id: { type: string, minLength: 1, maxLength: 255 }
    WebhookEventOrigin:
      type: object
      description: Who caused the change. Use it to ignore the events your own writes produce.
      required: [source]
      properties:
        source:
          type: string
          enum: [api, app, inbound, system]
        api_key_id:
          type: string
          format: uuid
          description: "`source: api` only."
        request_id:
          type: string
          description: "`source: api` only - the `X-Request-Id` of the call that caused the event."
        integration:
          type: string
          description: "`source: api` only - the `X-ViperFlow-Integration` header of that call."
        connection_id:
          type: string
          format: uuid
          description: "`source: inbound` only."
        provider:
          type: string
          description: "`source: inbound` only."
        external_event_id:
          type: string
          description: "`source: inbound` only."
    WebhookEvent:
      type: object
      description: >-
        The body ViperFlow POSTs to a webhook endpoint. The top-level keys always appear in this
        order and nested object keys are sorted, so a re-delivery is byte-identical. Sign and
        compare the raw bytes.
      required: [id, type, created_at, api_version, livemode, origin, data, previous]
      properties:
        id:
          type: string
          pattern: "^evt_[0-9a-f]{32}$"
          description: Stable across retries - the de-duplication key.
        type:
          type: string
          description: >-
            One of the 41 catalog types (`customer.*`, `order.*`, `payment.*`, `document.*`,
            `product.*`, `inventory.updated`, `warehouse.task.*`, `supplier.*`,
            `purchase_order.*`, `webhook.test`). New types may be added; only an endpoint
            subscribed to `*` receives them automatically.
          example: order.status_changed
        created_at: { type: string, format: date-time }
        api_version: { type: string, enum: [v1] }
        livemode:
          type: boolean
          const: true
          description: Always `true` - test-mode API keys never produce events.
        origin: { $ref: "#/components/schemas/WebhookEventOrigin" }
        data:
          description: >-
            The full entity object, identical to what the REST `GET .../{id}` endpoint returns
            (orders include `items`, products include `components` and `variant_prices`, documents
            include `lines` and `payments`). For a `*.deleted` event, a minimal snapshot of the
            deleted row with `"deleted": true`.
          oneOf:
            - $ref: "#/components/schemas/Customer"
            - $ref: "#/components/schemas/Order"
            - $ref: "#/components/schemas/Payment"
            - $ref: "#/components/schemas/Document"
            - $ref: "#/components/schemas/Product"
            - $ref: "#/components/schemas/InventoryItem"
            - $ref: "#/components/schemas/WarehouseTask"
            - $ref: "#/components/schemas/Supplier"
            - $ref: "#/components/schemas/PurchaseOrder"
            - type: object
              description: A deleted-row snapshot, or the `webhook.test` payload.
              additionalProperties: true
            - type: "null"
        previous:
          type: [object, "null"]
          description: >-
            The fields the trigger compared, as they were before the change - for example
            `{"status": "draft"}` on `order.status_changed`. `null` on creates and on plain
            updates. A hint, not a complete diff.
          additionalProperties: true
