# velik.ai Platform API — OpenAPI 3.1 contract for /api/v1 (hand-written, guarded).
#
# Source of truth for the public contract. Guard tests (tests/docs/*):
#   • every api/v1/** route has a path here and vice versa (methods + x-scope);
#   • every v1 response validates against the schemas below (additionalProperties: false);
#   • x-velik-scopes / x-velik-presets / x-velik-webhook-events / x-velik-mcp-tools
#     equal the code (lib/public-api/scopes.js, lib/webhooks/events.js, MCP registries);
#   • a change of this file requires a new entry (with the contract hash) in CHANGELOG.md.
# Served publicly at https://velik.ai/openapi.v1.yaml and rendered at https://velik.ai/developers/api.
openapi: 3.1.0
info:
  title: velik.ai Platform API
  version: 1.0.0
  summary: Orders, shipments, inventory, products, customers and webhooks for integrators (fulfilment centres, ERP/BI, AI agents).
  description: |
    REST API under `/api/v1` plus an MCP server at `/api/mcp` sharing the same operations, views and API keys.

    * **Authentication:** `Authorization: Bearer vk_…` — an API key the merchant issues in *Settings → Integrations → API keys*.
      One key = one account. Scopes are explicit (`resource:action`); `:write` does **not** imply `:read`.
    * **Plan:** Pro and above. Free accounts receive `403 plan_required`.
    * **Rate limit:** 300 requests/minute per key (`X-RateLimit-Limit`, `X-RateLimit-Remaining`, `Retry-After`). MCP: 120/minute.
    * **Errors:** `{ "error": { "code", "message", "details"? } }` with a stable snake_case `code`.
      A row that belongs to another account is `404 not_found` (never 403). A non-UUID id is `400 validation_failed`.
    * **Dry run:** send `X-Velik-Dry-Run: 1` on write operations to validate without writing (`dry_run: true` in the response).
    * **Pagination:** `limit` (default 50, max 200) + opaque `cursor` → `{ data, has_more, next_cursor }`. Customers use `limit`/`offset`/`total`.
    * **Versioning:** the path is the version. v1 only changes additively (see CHANGELOG.md).
    * Every response carries `X-Deploy-SHA` (the revision serving the request). No CORS — server-to-server only.
  contact:
    name: velik.ai developers
    url: https://velik.ai/developers
    email: support@velik.ai
  termsOfService: https://velik.ai/terms
servers:
  - url: https://velik.ai
    description: Production
security:
  - apiKey: []
tags:
  - name: orders
    description: Orders of the account (tenancy through the merchant's pages). `orders:read` / `orders:write`.
  - name: shipments
    description: Waybills created through the merchant's own courier integrations (Econt, Speedy, BoxNow). `shipments:read` / `shipments:write`.
  - name: inventory
    description: Stock levels and the stock ledger. `inventory:read` / `inventory:write`.
  - name: products
    description: 'Catalog. `products:read` to read; `products:write` to create products and change price / SKU / name / dimensions / active (never stock, cost or media).'
  - name: customers
    description: 'Customers (aggregated from real orders), saved segments and notes. `customers:read` to read; `customers:write` to manage segments, their members and customer notes (never emails, phones or consents).'
  - name: webhooks
    description: Webhook subscriptions and deliveries. `webhooks:read` / `webhooks:write`.

# ── Contract lists mirrored from the code (guard: tests/docs/contract-lists.test.js) ──
x-velik-scopes:
  - submit_brief
  - get_status
  - get_facts
  - orders:read
  - orders:write
  - shipments:read
  - shipments:write
  - inventory:read
  - inventory:write
  - products:read
  - products:write
  - customers:read
  - customers:write
  - webhooks:read
  - webhooks:write
x-velik-presets:
  mashinata: [submit_brief, get_status, get_facts]
  fulfilment:
    [
      orders:read,
      orders:write,
      shipments:read,
      shipments:write,
      inventory:read,
      inventory:write,
      products:read,
      webhooks:read,
      webhooks:write,
    ]
  readonly:
    [orders:read, shipments:read, inventory:read, products:read, customers:read, webhooks:read]
x-velik-webhook-events:
  - order.created
  - order.updated
  - order.status_changed
  - order.paid
  - shipment.created
  - shipment.status_changed
  - shipment.cancelled
  - inventory.low_stock
x-velik-rate-limits:
  rest_per_minute: 300
  mcp_per_minute: 120
  mcp_max_batch: 20
  webhook_test_retry_per_minute: 30
x-velik-mcp-tools:
  - { name: submit_brief, scope: submit_brief }
  - { name: get_status, scope: get_status }
  - { name: get_facts, scope: get_facts }
  - { name: list_orders, scope: orders:read }
  - { name: get_order, scope: orders:read }
  - { name: get_shipment, scope: shipments:read }
  - { name: get_inventory_stock, scope: inventory:read }
  - { name: list_stock_movements, scope: inventory:read }
  - { name: list_products, scope: products:read }
  - { name: get_product, scope: products:read }
  - { name: list_customers, scope: customers:read }
  - { name: update_order, scope: orders:write }
  - { name: record_stock_movement, scope: inventory:write }
  - { name: create_shipment, scope: shipments:write }
  - { name: cancel_shipment, scope: shipments:write }
  - { name: list_webhooks, scope: webhooks:read }
  - { name: create_webhook, scope: webhooks:write }
  - { name: update_webhook, scope: webhooks:write }
  - { name: delete_webhook, scope: webhooks:write }
  - { name: test_webhook, scope: webhooks:write }
  - { name: list_webhook_deliveries, scope: webhooks:read }
  - { name: create_product, scope: products:write }
  - { name: update_product, scope: products:write }
  - { name: create_segment, scope: customers:write }
  - { name: update_segment, scope: customers:write }
  - { name: delete_segment, scope: customers:write }
  - { name: add_segment_members, scope: customers:write }
  - { name: remove_segment_members, scope: customers:write }
  - { name: add_customer_note, scope: customers:write }
  - { name: list_segments, scope: customers:read }

paths:
  /api/v1/orders:
    get:
      tags: [orders]
      operationId: listOrders
      x-scope: orders:read
      summary: List orders
      description: |
        Newest first, keyset pagination. Without `status` the list excludes `archived` (pass `status=archived` to see them).
        `since` filters on `updated_at ≥` — use it for polling when you do not want webhooks.
      parameters:
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/cursor'
        - name: status
          in: query
          schema: { $ref: '#/components/schemas/OrderStatus' }
        - name: payment
          in: query
          description: Payment method (`cod`, `bank`, `card`, `free`, …).
          schema: { type: string }
        - name: from
          in: query
          description: ISO 8601 — `created_at ≥`.
          schema: { type: string, format: date-time }
        - name: to
          in: query
          description: ISO 8601 — `created_at ≤`.
          schema: { type: string, format: date-time }
        - name: since
          in: query
          description: ISO 8601 — `updated_at ≥` (polling).
          schema: { type: string, format: date-time }
        - name: page_id
          in: query
          schema: { type: string, format: uuid }
        - name: product_id
          in: query
          description: Orders of pages selling this product.
          schema: { type: string, format: uuid }
        - name: courier
          in: query
          description: Courier slug, or `none` = orders without a tracking number (the fulfilment work list).
          schema: { type: string }
        - name: shipment_status
          in: query
          schema: { $ref: '#/components/schemas/ShipmentStatus' }
        - name: q
          in: query
          description: Search in customer name / phone / email / tracking number.
          schema: { type: string, maxLength: 100 }
      responses:
        '200':
          description: A page of orders.
          headers:
            X-RateLimit-Limit: { $ref: '#/components/headers/X-RateLimit-Limit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/X-RateLimit-Remaining' }
            X-Deploy-SHA: { $ref: '#/components/headers/X-Deploy-SHA' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OrderList' }
        '400': { $ref: '#/components/responses/ValidationFailed' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /api/v1/orders/{id}:
    parameters:
      - $ref: '#/components/parameters/id'
    get:
      tags: [orders]
      operationId: getOrder
      x-scope: orders:read
      summary: Get an order
      description: >-
        One order of this account with its shipment summary. Ids are UUIDs; an order of another account is `404 not_found`.
      responses:
        '200':
          description: The order with its shipment summary.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OrderEnvelope' }
        '400': { $ref: '#/components/responses/ValidationFailed' }
        '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
      x-scope: orders:write
      summary: Update status, tracking, courier, notes
      description: |
        The status goes through the same path as the merchant's admin: `shipped` emails/SMS the customer (per the
        merchant's notification settings; `notify_customer: false` suppresses it), `cancelled` releases reserved stock,
        `returned`/`refunded` restock. Setting the same status again is a no-op (`status_changed: false`).
        Fields outside the allowed list → `400 validation_failed` with `details.allowed`.
        With `X-Velik-Dry-Run: 1` nothing is written and the response is `OrderPatchDryRun`.
      parameters:
        - $ref: '#/components/parameters/dryRun'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/OrderPatch' }
      responses:
        '200':
          description: Updated order (or the dry-run preview).
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/OrderPatchResult'
                  - $ref: '#/components/schemas/OrderPatchDryRun'
        '400': { $ref: '#/components/responses/ValidationFailed' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /api/v1/orders/{id}/shipment:
    parameters:
      - $ref: '#/components/parameters/id'
    get:
      tags: [shipments]
      operationId: getOrderShipment
      x-scope: shipments:read
      summary: Latest shipment of an order (or null)
      description: >-
        The newest shipment (waybill) of the order, or `data: null` when none was created yet.
      responses:
        '200':
          description: 'The latest shipment of the order, or `data: null` when none was created.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OrderShipmentEnvelope' }
        '400': { $ref: '#/components/responses/ValidationFailed' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /api/v1/shipments:
    post:
      tags: [shipments]
      operationId: createShipment
      x-scope: shipments:write
      summary: Create a waybill through the merchant's courier
      description: |
        Uses the courier / delivery mode / office the customer chose at checkout unless overridden. Cash-on-delivery
        is computed server-side from the order. Moves a `new`/`confirmed` order to `processing` and emits
        `shipment.created` (+ `order.status_changed`). An order with a live shipment → `409 conflict`
        (`details.shipment`). Courier refusal / outage → `502 courier_error` (`details.reason`, `details.detail`).
        With `X-Velik-Dry-Run: 1` everything up to the courier call is resolved and returned as `ShipmentCreateDryRun`.
      parameters:
        - $ref: '#/components/parameters/dryRun'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ShipmentCreate' }
      responses:
        '200':
          description: The created shipment (or the dry-run resolution).
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/ShipmentEnvelope'
                  - $ref: '#/components/schemas/ShipmentCreateDryRun'
        '400': { $ref: '#/components/responses/ValidationFailed' }
        '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' }
        '502': { $ref: '#/components/responses/CourierError' }
  /api/v1/shipments/{id}:
    parameters:
      - $ref: '#/components/parameters/id'
    get:
      tags: [shipments]
      operationId: getShipment
      x-scope: shipments:read
      summary: Get a shipment
      description: >-
        A shipment (waybill) by id: courier, tracking number, normalized status and the raw courier status events.
      responses:
        '200':
          description: The shipment with its status events.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ShipmentEnvelope' }
        '400': { $ref: '#/components/responses/ValidationFailed' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /api/v1/shipments/{id}/label:
    parameters:
      - $ref: '#/components/parameters/id'
    get:
      tags: [shipments]
      operationId: getShipmentLabel
      x-scope: shipments:read
      summary: Download the PDF label
      description: >-
        The courier label as `application/pdf` (attachment). The PDF is fetched from the courier on demand and is not cached.
      responses:
        '200':
          description: 'The courier label as `application/pdf` (`Content-Disposition: attachment; filename="label_<tracking>.pdf"`).'
          content:
            application/pdf:
              schema: { type: string, format: binary }
        '400': { $ref: '#/components/responses/ValidationFailed' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '502': { $ref: '#/components/responses/CourierError' }
  /api/v1/shipments/{id}/cancel:
    parameters:
      - $ref: '#/components/parameters/id'
    post:
      tags: [shipments]
      operationId: cancelShipment
      x-scope: shipments:write
      summary: Cancel a waybill at the courier
      description: |
        Speedy and Econt confirm the cancellation; BoxNow has no cancel API (marked locally, `note` explains).
        Frees the order for a new waybill; a `shipped` order reverts to `processing` when no other live shipment
        remains. Already cancelled / delivered / refused / returned → `409 conflict`.
        With `X-Velik-Dry-Run: 1` returns `ShipmentCancelDryRun` (ownership + `cancellable`) without calling the courier.
      parameters:
        - $ref: '#/components/parameters/dryRun'
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ShipmentCancel' }
      responses:
        '200':
          description: Cancellation result (or the dry-run check).
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/ShipmentCancelResult'
                  - $ref: '#/components/schemas/ShipmentCancelDryRun'
        '400': { $ref: '#/components/responses/ValidationFailed' }
        '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' }
        '502': { $ref: '#/components/responses/CourierError' }
  /api/v1/inventory/stock:
    get:
      tags: [inventory]
      operationId: getInventoryStock
      x-scope: inventory:read
      summary: Stock levels
      description: 'Active physical products with tracked quantity, variants and low-stock thresholds. `tracked: false` = the product does not track stock.'
      parameters:
        - name: product_id
          in: query
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Stock per product.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/StockResponse' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /api/v1/inventory/movements:
    get:
      tags: [inventory]
      operationId: listStockMovements
      x-scope: inventory:read
      summary: Stock ledger
      description: >-
        Stock movements newest first (sale, release, return, delivery, correction), keyset pagination. Filters: `product_id`, `order_id`, `type`, `from`, `to` (all validated up front).
      parameters:
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/cursor'
        - name: product_id
          in: query
          schema: { type: string, format: uuid }
        - name: order_id
          in: query
          schema: { type: string, format: uuid }
        - name: type
          in: query
          schema: { $ref: '#/components/schemas/MovementType' }
        - name: from
          in: query
          schema: { type: string, format: date-time }
        - name: to
          in: query
          schema: { type: string, format: date-time }
      responses:
        '200':
          description: A page of movements, newest first.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MovementList' }
        '400': { $ref: '#/components/responses/ValidationFailed' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
    post:
      tags: [inventory]
      operationId: recordStockMovement
      x-scope: inventory:write
      summary: Record a delivery or a correction
      description: |
        `delivery` (qty > 0; optional `unit_cost` updates the weighted average cost) or `correction` (signed qty).
        The first delivery on an untracked product enables stock tracking (`enables_tracking` in the dry run).
        `sale`/`release`/`return` are system movements and cannot be recorded manually.
      parameters:
        - $ref: '#/components/parameters/dryRun'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/MovementCreate' }
      responses:
        '200':
          description: Applied (or the dry-run validation).
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/MovementResult'
                  - $ref: '#/components/schemas/MovementDryRun'
        '400': { $ref: '#/components/responses/ValidationFailed' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/MigrationPending' }
  /api/v1/products:
    post:
      tags: [products]
      operationId: createProduct
      x-scope: products:write
      summary: Create a product
      description: '`name` and `price` are required. Defaults: `delivery_type: physical`, `active: true`, no images. Stock quantity, cost, images, files and bundles cannot be set through the API (stock moves through `POST /api/v1/inventory/movements`). Unknown fields → `400 validation_failed` with `details.allowed`. With `X-Velik-Dry-Run: 1` returns `ProductCreateDryRun`.'
      parameters:
        - $ref: '#/components/parameters/dryRun'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ProductCreate' }
      responses:
        '200':
          description: The created product (or the dry-run preview).
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/ProductEnvelope'
                  - $ref: '#/components/schemas/ProductCreateDryRun'
        '400': { $ref: '#/components/responses/ValidationFailed' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
    get:
      tags: [products]
      operationId: listProducts
      x-scope: products:read
      summary: List products
      description: >-
        The catalog with variants, newest first. Active products only unless `status=all`; `delivery_type` must be `physical` or `digital`.
      parameters:
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/cursor'
        - name: status
          in: query
          description: '`active` (default) or `all` (includes deactivated products — they still appear in orders).'
          schema: { type: string, enum: [active, all] }
        - name: delivery_type
          in: query
          schema: { type: string }
        - name: sku
          in: query
          description: Exact SKU.
          schema: { type: string }
      responses:
        '200':
          description: A page of products with variants.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ProductList' }
        '400': { $ref: '#/components/responses/ValidationFailed' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /api/v1/products/{id}:
    parameters:
      - $ref: '#/components/parameters/id'
    get:
      tags: [products]
      operationId: getProduct
      x-scope: products:read
      summary: Get a product (also deactivated ones)
      description: >-
        One product with its variants. Deactivated products are readable because orders keep referring to them.
      responses:
        '200':
          description: The product with its variants.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ProductEnvelope' }
        '400': { $ref: '#/components/responses/ValidationFailed' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
    patch:
      tags: [products]
      operationId: updateProduct
      x-scope: products:write
      summary: Update price, SKU, name, dimensions, active…
      description: 'Any subset of `ProductPatch`. `active: false` deactivates (the product stays in past orders). Stock quantity is not writable here — use inventory movements. With `X-Velik-Dry-Run: 1` returns `ProductPatchDryRun` (current product + normalized changes).'
      parameters:
        - $ref: '#/components/parameters/dryRun'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ProductPatch' }
      responses:
        '200':
          description: The updated product (or the dry-run preview).
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/ProductEnvelope'
                  - $ref: '#/components/schemas/ProductPatchDryRun'
        '400': { $ref: '#/components/responses/ValidationFailed' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /api/v1/customers:
    get:
      tags: [customers]
      operationId: listCustomers
      x-scope: customers:read
      summary: List customers
      description: |
        Customers are not rows — they are aggregated from real orders (key = lower-cased email, else phone), excluding
        cancelled / archived / refunded orders and imported contacts. Offset pagination; newest last order first.
      parameters:
        - $ref: '#/components/parameters/limit'
        - name: offset
          in: query
          schema: { type: integer, minimum: 0, default: 0 }
        - name: q
          in: query
          description: Search in name / email / phone.
          schema: { type: string }
      responses:
        '200':
          description: A page of customers.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CustomerList' }
        '400': { $ref: '#/components/responses/ValidationFailed' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /api/v1/segments:
    get:
      tags: [customers]
      operationId: listSegments
      x-scope: customers:read
      summary: Saved customer segments
      description: >-
        The saved segments of the account (computed from filters, or membership segments filled by id).
      responses:
        '200':
          description: All saved segments of the account (no pagination).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SegmentList' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
    post:
      tags: [customers]
      operationId: createSegment
      x-scope: customers:write
      summary: Create a segment (computed or membership)
      description: |
        With `filters` the segment is **computed** from orders (keys: `minTotal`, `maxTotal`, `minOrders`, `maxOrders`,
        `lastOrderBeforeDaysAgo`, `lastOrderAfterDate`, `lastOrderBeforeDate`, `productId`, `pageId`, `inSegment`,
        `hasEmail`, `hasPhone`, `hasAbandonedCart`; unknown keys → `400`). Without `filters` it is a **membership**
        segment (`filters` becomes `{ inSegment: <its own id> }`) that you fill with `POST /api/v1/segments/{id}/members`
        — the way to push a list from a CRM. The merchant can email / SMS the segment from the admin.
      parameters:
        - $ref: '#/components/parameters/dryRun'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SegmentCreate' }
      responses:
        '200':
          description: The created segment (or the dry-run preview).
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/SegmentCreated'
                  - $ref: '#/components/schemas/SegmentCreateDryRun'
        '400': { $ref: '#/components/responses/ValidationFailed' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /api/v1/segments/{id}:
    parameters:
      - $ref: '#/components/parameters/id'
    patch:
      tags: [customers]
      operationId: updateSegment
      x-scope: customers:write
      summary: Rename a segment or replace its filters
      description: 'Setting `filters` to `{}` turns the segment into a membership segment. With `X-Velik-Dry-Run: 1` returns `SegmentPatchDryRun`.'
      parameters:
        - $ref: '#/components/parameters/dryRun'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SegmentPatch' }
      responses:
        '200':
          description: The updated segment (or the dry-run preview).
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/SegmentEnvelope'
                  - $ref: '#/components/schemas/SegmentPatchDryRun'
        '400': { $ref: '#/components/responses/ValidationFailed' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
    delete:
      tags: [customers]
      operationId: deleteSegment
      x-scope: customers:write
      summary: Delete a segment (memberships go with it)
      description: >-
        Deletes the segment; its membership rows are removed with it.
      parameters:
        - $ref: '#/components/parameters/dryRun'
      responses:
        '200':
          description: Deleted (or the dry-run check).
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/SegmentDeleted'
                  - $ref: '#/components/schemas/SegmentDeleteDryRun'
        '400': { $ref: '#/components/responses/ValidationFailed' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /api/v1/segments/{id}/members:
    parameters:
      - $ref: '#/components/parameters/id'
    post:
      tags: [customers]
      operationId: addSegmentMembers
      x-scope: customers:write
      summary: Add customers to a membership segment
      description: 'Up to 500 customers per call, each `{ email }` or `{ phone }` (email wins; lower-cased). Already-present customers are counted in `skipped`. A computed (filter-based) segment answers `409 conflict` (`details.kind: filter_segment`). The customer does not need an order yet — the key is stored as given.'
      parameters:
        - $ref: '#/components/parameters/dryRun'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SegmentMembersBody' }
      responses:
        '200':
          description: Counts (or the dry-run preview with the normalized keys).
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/SegmentMembersAdded'
                  - $ref: '#/components/schemas/SegmentMembersDryRun'
        '400': { $ref: '#/components/responses/ValidationFailed' }
        '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' }
    delete:
      tags: [customers]
      operationId: removeSegmentMembers
      x-scope: customers:write
      summary: Remove customers from a segment
      description: >-
        Removes customers (by email or phone key) from a membership segment. Unknown keys are ignored.
      parameters:
        - $ref: '#/components/parameters/dryRun'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SegmentMembersBody' }
      responses:
        '200':
          description: How many memberships were removed (or the dry-run preview).
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/SegmentMembersRemoved'
                  - $ref: '#/components/schemas/SegmentMembersDryRun'
        '400': { $ref: '#/components/responses/ValidationFailed' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /api/v1/customers/{key}/notes:
    parameters:
      - $ref: '#/components/parameters/customerKey'
    post:
      tags: [customers]
      operationId: addCustomerNote
      x-scope: customers:write
      summary: Add an internal note to a customer
      description: "The note appears in the merchant's Customers tab. `key` is the customer key from `GET /api/v1/customers` (email, else phone); it is lower-cased. Text ≤ 2000 characters."
      parameters:
        - $ref: '#/components/parameters/dryRun'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CustomerNoteCreate' }
      responses:
        '200':
          description: The stored note (or the dry-run preview).
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/CustomerNoteCreated'
                  - $ref: '#/components/schemas/CustomerNoteDryRun'
        '400': { $ref: '#/components/responses/ValidationFailed' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /api/v1/webhooks:
    get:
      tags: [webhooks]
      operationId: listWebhooks
      x-scope: webhooks:read
      summary: List webhook subscriptions
      description: >-
        All subscriptions of the account with their events, status and the last delivery result. Secrets are never returned here.
      responses:
        '200':
          description: Subscriptions (never the secret) and the list of subscribable events.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WebhookList' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
    post:
      tags: [webhooks]
      operationId: createWebhook
      x-scope: webhooks:write
      summary: Create a subscription (secret returned once)
      description: |
        `url` must be `https://`, without credentials, and must not point at a loopback, private, link-local or
        metadata address, at `localhost` / `*.local` / `*.internal`, or at velik.ai itself (the hostname is also
        resolved and checked). At least one event. Max 20 subscriptions per account
        (`409 conflict`). The signing `secret` is returned **only** in this response — store it.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WebhookCreate' }
      responses:
        '200':
          description: The subscription and its secret.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WebhookCreated' }
        '400': { $ref: '#/components/responses/ValidationFailed' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/Conflict' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /api/v1/webhooks/{id}:
    parameters:
      - $ref: '#/components/parameters/id'
    patch:
      tags: [webhooks]
      operationId: updateWebhook
      x-scope: webhooks:write
      summary: Update url / events / description / active
      description: '`active: true` also clears the "dead" notification flag so a recovered endpoint is emailed again on the next failure.'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WebhookPatch' }
      responses:
        '200':
          description: The updated subscription.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WebhookEnvelope' }
        '400': { $ref: '#/components/responses/ValidationFailed' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
    delete:
      tags: [webhooks]
      operationId: deleteWebhook
      x-scope: webhooks:write
      summary: Delete a subscription (its deliveries go with it)
      description: >-
        Deletes the subscription and its delivery history. Pending deliveries stop immediately.
      responses:
        '200':
          description: Deleted.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WebhookDeleted' }
        '400': { $ref: '#/components/responses/ValidationFailed' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /api/v1/webhooks/{id}/deliveries:
    parameters:
      - $ref: '#/components/parameters/id'
    get:
      tags: [webhooks]
      operationId: listWebhookDeliveries
      x-scope: webhooks:read
      summary: Recent deliveries of a subscription
      description: >-
        The delivery log of one subscription, newest first (`limit` up to 200): status, attempt, HTTP code, next attempt.
      parameters:
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 200, default: 30 }
      responses:
        '200':
          description: Deliveries, newest first.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DeliveryList' }
        '400': { $ref: '#/components/responses/ValidationFailed' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /api/v1/webhooks/{id}/test:
    parameters:
      - $ref: '#/components/parameters/id'
    post:
      tags: [webhooks]
      operationId: testWebhook
      x-scope: webhooks:write
      summary: Send a `test` event and wait for the first attempt
      x-velik-envelope: { $ref: '#/components/schemas/WebhookEnvelopeTest' }
      description: 'Delivers `{ event: "test", data: { message, webhook_id } }` to this subscription only and returns the first attempt (status code). Inactive subscription → `409 conflict`.'
      responses:
        '200':
          description: The first delivery attempt.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WebhookTestResult' }
        '400': { $ref: '#/components/responses/ValidationFailed' }
        '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' }
  /api/v1/webhook-deliveries/{id}/retry:
    parameters:
      - $ref: '#/components/parameters/id'
    post:
      tags: [webhooks]
      operationId: retryWebhookDelivery
      x-scope: webhooks:write
      summary: Retry a delivery now
      description: Re-sends a `pending`/`failed`/`dead` delivery immediately (same event id, fresh timestamp and signature). Already delivered → `409 conflict`.
      responses:
        '200':
          description: The attempt result and the refreshed delivery.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DeliveryRetryResult' }
        '400': { $ref: '#/components/responses/ValidationFailed' }
        '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' }

# ── Webhooks (OpenAPI 3.1 top-level): what velik.ai POSTs to your endpoint ──
webhooks:
  order.created:
    post:
      summary: A new order was placed at checkout
      requestBody:
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WebhookEnvelopeOrderCreated' }
      responses:
        '200': { description: Return any 2xx to acknowledge. }
  order.updated:
    post:
      summary: Order fields outside the status changed (tracking, courier, notes, address)
      requestBody:
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WebhookEnvelopeOrderUpdated' }
      responses:
        '200': { description: Return any 2xx to acknowledge. }
  order.status_changed:
    post:
      summary: The order status changed (merchant, API, courier mirror or payment)
      requestBody:
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WebhookEnvelopeOrderStatusChanged' }
      responses:
        '200': { description: Return any 2xx to acknowledge. }
  order.paid:
    post:
      summary: The order was paid (card via Stripe, or marked paid by the merchant)
      requestBody:
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WebhookEnvelopeOrderPaid' }
      responses:
        '200': { description: Return any 2xx to acknowledge. }
  shipment.created:
    post:
      summary: A waybill was created
      requestBody:
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WebhookEnvelopeShipmentCreated' }
      responses:
        '200': { description: Return any 2xx to acknowledge. }
  shipment.status_changed:
    post:
      summary: The courier reported a new normalized status
      requestBody:
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WebhookEnvelopeShipmentStatusChanged' }
      responses:
        '200': { description: Return any 2xx to acknowledge. }
  shipment.cancelled:
    post:
      summary: A waybill was cancelled
      requestBody:
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WebhookEnvelopeShipmentCancelled' }
      responses:
        '200': { description: Return any 2xx to acknowledge. }
  inventory.low_stock:
    post:
      summary: A tracked product fell to or below its low-stock threshold (daily check)
      requestBody:
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WebhookEnvelopeLowStock' }
      responses:
        '200': { description: Return any 2xx to acknowledge. }

components:
  securitySchemes:
    apiKey:
      type: http
      scheme: bearer
      bearerFormat: vk_…
      description: API key issued in Settings → Integrations → API keys. One key = one account; scopes are fixed at issue time.
  headers:
    X-RateLimit-Limit:
      description: Requests per minute allowed for this key (300).
      schema: { type: integer }
    X-RateLimit-Remaining:
      description: Requests left in the current minute.
      schema: { type: integer }
    Retry-After:
      description: Seconds to wait (on 429).
      schema: { type: integer }
    X-Deploy-SHA:
      description: The revision (10-character git sha) that served the request.
      schema: { type: string }
  parameters:
    id:
      name: id
      in: path
      required: true
      description: UUID. A non-UUID value is `400 validation_failed`; a UUID from another account is `404 not_found`.
      schema: { type: string, format: uuid }
    limit:
      name: limit
      in: query
      schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
    cursor:
      name: cursor
      in: query
      description: Opaque cursor from a previous `next_cursor`.
      schema: { type: string }
    customerKey:
      name: key
      in: path
      required: true
      description: Customer key — lower-cased email, else phone (the `key` field of `Customer`). URL-encode it.
      schema: { type: string, maxLength: 200 }
    dryRun:
      name: X-Velik-Dry-Run
      in: header
      description: '`1` / `true` / `yes` → validate everything, write nothing, respond with `dry_run: true`.'
      schema: { type: string, enum: ['1', 'true', 'yes'] }
  responses:
    ValidationFailed:
      description: '`validation_failed` — bad input (also a non-UUID id).'
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { error: { code: validation_failed, message: id must be a UUID. } }
    Unauthorized:
      description: '`unauthorized` — missing, invalid or revoked key. `WWW-Authenticate: Bearer realm="velik.ai API"`.'
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example:
            {
              error:
                {
                  code: unauthorized,
                  message: Invalid or revoked API key. Issue one in Settings → API keys.,
                },
            }
    Forbidden:
      description: '`scope_missing` (with `scope`), `plan_required` (Free plan) or `plan_lookup_failed` (fail-closed).'
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            scope_missing:
              value:
                {
                  error:
                    {
                      code: scope_missing,
                      message: 'This key does not have the customers:read scope.',
                      scope: 'customers:read',
                    },
                }
            plan_required:
              value:
                {
                  error:
                    {
                      code: plan_required,
                      message: The platform API is available on the Pro plan and above.,
                    },
                }
    NotFound:
      description: '`not_found` — no such row in this account (also for rows of other accounts).'
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { error: { code: not_found, message: Order not found. } }
    Conflict:
      description: '`conflict` — state conflict (live shipment exists, already cancelled, webhook limit, already delivered).'
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    RateLimited:
      description: '`rate_limited` — over 300 requests/minute for this key. `Retry-After` in seconds.'
      headers:
        Retry-After: { $ref: '#/components/headers/Retry-After' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    CourierError:
      description: '`courier_error` — the courier refused or did not answer (`details.reason`, `details.detail`).'
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    MigrationPending:
      description: '`migration_pending` — the feature is not enabled on this deployment yet.'
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
  schemas:
    Error:
      type: object
      additionalProperties: false
      required: [error]
      properties:
        error:
          type: object
          additionalProperties: false
          required: [code, message]
          properties:
            code:
              type: string
              enum:
                - unauthorized
                - scope_missing
                - plan_required
                - plan_lookup_failed
                - rate_limited
                - method_not_allowed
                - validation_failed
                - not_found
                - conflict
                - courier_error
                - migration_pending
                - internal_error
            message:
              { type: string, description: 'English, human readable. Not stable — key on `code`.' }
            scope: { type: string, description: 'On `scope_missing`: the scope the route needs.' }
            details:
              description: Optional machine-readable extras (`allowed`, `kind`, `reason`, `detail`, `shipment`).
              type: [object, array]
              items: {}
    OrderStatus:
      type: string
      enum:
        [new, confirmed, processing, shipped, delivered, cancelled, returned, refunded, archived]
    ShipmentStatus:
      type: string
      description: velik.ai's normalized courier status (the raw courier text is in `status_events`).
      enum: [pending, picked_up, in_transit, delivered, cancelled, refused, returned]
    MovementType:
      type: string
      enum: [sale, release, return, delivery, correction]
    Timestamp:
      type: [string, 'null']
      format: date-time
    LineItem:
      type: object
      description: 'One line of the frozen order bill (amounts in cents). Legacy orders have `line_items: null` and carry the single product in `product_name` / `quantity` / `unit_price`.'
      additionalProperties: true
      properties:
        kind: { type: string, enum: [main, component, gift, bump, extra, free_shipping] }
        product_id: { type: [string, 'null'], format: uuid }
        variant_id: { type: [string, 'null'], format: uuid }
        name: { type: string }
        sku: { type: [string, 'null'] }
        qty: { type: integer }
        unit_price_cents: { type: integer }
        total_cents: { type: integer }
        delivery_type: { type: string }
        digital: { type: boolean }
        note: { type: string }
    ShipmentSummary:
      type: object
      additionalProperties: false
      required: [id, courier_slug, tracking_number, status, last_status_label, last_status_at]
      properties:
        id: { type: string, format: uuid }
        courier_slug: { type: [string, 'null'] }
        tracking_number: { type: [string, 'null'] }
        status: { $ref: '#/components/schemas/ShipmentStatus' }
        last_status_label: { type: [string, 'null'] }
        last_status_at: { $ref: '#/components/schemas/Timestamp' }
    Order:
      type: object
      additionalProperties: false
      required:
        - id
        - number
        - order_number
        - status
        - payment_status
        - payment_method
        - funnel_step
        - parent_order_id
        - total
        - delivery_price
        - discount_amount
        - coupon_code
        - refund_amount_cents
        - refunded_at
        - customer
        - shipping
        - courier
        - tracking_number
        - tracking_url
        - line_items
        - product_name
        - product_sku
        - quantity
        - unit_price
        - notes
        - admin_notes
        - return_note
        - page_id
        - invoice_number
        - shipment
        - created_at
        - updated_at
      properties:
        id: { type: string, format: uuid }
        number:
          { type: [integer, 'null'], description: The merchant-facing order number (per account). }
        order_number: { type: [integer, 'null'], description: The platform-wide order number. }
        status: { $ref: '#/components/schemas/OrderStatus' }
        payment_status: { type: [string, 'null'], description: '`pending`, `paid`, `refunded`, …' }
        payment_method: { type: [string, 'null'], description: '`cod`, `bank`, `card`, `free`, …' }
        funnel_step:
          {
            type: [string, 'null'],
            description: '`main`, `upsell`, `downsell`, … for funnel orders.',
          }
        parent_order_id: { type: [string, 'null'], format: uuid }
        total:
          {
            type: [number, 'null'],
            description: Grand total in the page currency (BGN/EUR per the page).,
          }
        delivery_price: { type: [number, 'null'] }
        discount_amount: { type: [number, 'null'] }
        coupon_code: { type: [string, 'null'] }
        refund_amount_cents: { type: [integer, 'null'] }
        refunded_at: { $ref: '#/components/schemas/Timestamp' }
        customer:
          type: object
          additionalProperties: false
          required: [name, email, phone, vat]
          properties:
            name: { type: [string, 'null'] }
            email: { type: [string, 'null'] }
            phone: { type: [string, 'null'] }
            vat:
              {
                type: [string, 'null'],
                description: VAT / company id when the customer asked for an invoice.,
              }
        shipping:
          type: object
          additionalProperties: false
          required:
            [address, city, zip, method, courier_slug, courier_delivery_mode, courier_office_id]
          properties:
            address: { type: [string, 'null'] }
            city: { type: [string, 'null'] }
            zip: { type: [string, 'null'] }
            method:
              { type: [string, 'null'], description: The delivery method label chosen at checkout. }
            courier_slug:
              {
                type: [string, 'null'],
                description: '`econt` / `speedy` / `boxnow` chosen at checkout.',
              }
            courier_delivery_mode: { type: [string, 'null'], description: '`office` or `home`.' }
            courier_office_id: { type: [string, integer, 'null'] }
        courier:
          {
            type: [string, 'null'],
            description: The courier actually used (slug or free text set through the API).,
          }
        tracking_number: { type: [string, 'null'] }
        tracking_url: { type: [string, 'null'] }
        line_items:
          type: [array, 'null']
          items: { $ref: '#/components/schemas/LineItem' }
        product_name: { type: [string, 'null'] }
        product_sku: { type: [string, 'null'] }
        quantity: { type: [integer, 'null'] }
        unit_price: { type: [number, 'null'] }
        notes: { type: [string, 'null'], description: The customer's note at checkout. }
        admin_notes: { type: [string, 'null'], description: Internal notes (merchant / API). }
        return_note: { type: [string, 'null'] }
        page_id: { type: [string, 'null'], format: uuid }
        invoice_number: { type: [integer, string, 'null'] }
        shipment:
          oneOf:
            - $ref: '#/components/schemas/ShipmentSummary'
            - type: 'null'
        created_at: { type: string, format: date-time }
        updated_at: { $ref: '#/components/schemas/Timestamp' }
    StatusEvent:
      type: object
      additionalProperties: true
      properties:
        at: { type: string }
        code: { type: [string, integer, 'null'] }
        label: { type: [string, 'null'] }
    Shipment:
      type: object
      additionalProperties: false
      required:
        - id
        - order_id
        - courier_slug
        - tracking_number
        - status
        - last_status_label
        - last_status_at
        - status_events
        - delivery_mode
        - office_id
        - recipient_address
        - cod_amount
        - shipping_cost
        - parcel_weight_g
        - parcel_dim_cm
        - sender_origin
        - shipment_terms
        - created_at
        - updated_at
      properties:
        id: { type: string, format: uuid }
        order_id: { type: string, format: uuid }
        courier_slug: { type: [string, 'null'] }
        tracking_number: { type: [string, 'null'] }
        status: { $ref: '#/components/schemas/ShipmentStatus' }
        last_status_label: { type: [string, 'null'] }
        last_status_at: { $ref: '#/components/schemas/Timestamp' }
        status_events:
          type: array
          items: { $ref: '#/components/schemas/StatusEvent' }
        delivery_mode: { type: [string, 'null'], description: '`office` or `home`.' }
        office_id: { type: [string, integer, 'null'] }
        recipient_address:
          { type: [object, string, 'null'], description: 'Home delivery: `{ city, street }`.' }
        cod_amount: { type: [number, 'null'] }
        shipping_cost: { type: [number, 'null'] }
        parcel_weight_g: { type: [integer, 'null'] }
        parcel_dim_cm: { type: [object, 'null'] }
        sender_origin:
          {
            type: [object, 'null'],
            description: '`{ mode: address }` or `{ mode: office, office_id, office_label? }`.',
          }
        shipment_terms:
          {
            type: [object, 'null'],
            description: '`{ cod_type, service_payer?, open_before, refusal_payer, econt_cd_template? }`.',
          }
        created_at: { type: string, format: date-time }
        updated_at: { $ref: '#/components/schemas/Timestamp' }
    Variant:
      type: object
      additionalProperties: false
      required: [id, type, name, value, price_delta, stock_quantity, image_url, position]
      properties:
        id: { type: string, format: uuid }
        type: { type: [string, 'null'] }
        name: { type: [string, 'null'] }
        value: { type: [string, 'null'] }
        price_delta: { type: [number, 'null'] }
        stock_quantity: { type: [integer, 'null'] }
        image_url: { type: [string, 'null'] }
        position: { type: [integer, 'null'] }
    Product:
      type: object
      additionalProperties: false
      required:
        - id
        - name
        - marketing_name
        - sku
        - price
        - original_price
        - active
        - delivery_type
        - offer_type
        - description
        - images
        - stock_quantity
        - block_on_zero_stock
        - low_stock_threshold
        - weight_g
        - dim_cm
        - shipping_short_name
        - shipping_category
        - catalog_hidden
        - language
        - variants
        - created_at
        - updated_at
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        marketing_name: { type: [string, 'null'] }
        sku: { type: [string, 'null'] }
        price: { type: [number, 'null'] }
        original_price: { type: [number, 'null'] }
        active: { type: boolean }
        delivery_type: { type: [string, 'null'], description: '`physical`, `digital`, `free`, …' }
        offer_type: { type: [string, 'null'] }
        description: { type: [string, 'null'] }
        images:
          type: array
          items: { type: string }
        stock_quantity: { type: [integer, 'null'], description: '`null` = stock not tracked.' }
        block_on_zero_stock: { type: boolean }
        low_stock_threshold: { type: [integer, 'null'] }
        weight_g: { type: [integer, 'null'] }
        dim_cm:
          type: [object, 'null']
          additionalProperties: false
          properties:
            l: { type: [number, 'null'] }
            w: { type: [number, 'null'] }
            h: { type: [number, 'null'] }
        shipping_short_name:
          { type: [string, 'null'], description: Short name printed on courier labels. }
        shipping_category: { type: [string, 'null'] }
        catalog_hidden: { type: boolean }
        language: { type: [string, 'null'] }
        variants:
          type: array
          items: { $ref: '#/components/schemas/Variant' }
        created_at: { type: string, format: date-time }
        updated_at: { $ref: '#/components/schemas/Timestamp' }
    Movement:
      type: object
      additionalProperties: false
      required: [id, product_id, variant_id, order_id, type, qty, note, created_at]
      properties:
        id: { type: string, format: uuid }
        product_id: { type: string, format: uuid }
        variant_id: { type: [string, 'null'], format: uuid }
        order_id: { type: [string, 'null'], format: uuid }
        type: { $ref: '#/components/schemas/MovementType' }
        qty: { type: integer, description: Signed quantity. }
        note: { type: [string, 'null'] }
        created_at: { type: string, format: date-time }
    StockVariant:
      type: object
      additionalProperties: false
      required: [id, label, stock_quantity]
      properties:
        id: { type: string, format: uuid }
        label: { type: [string, 'null'] }
        stock_quantity: { type: [integer, 'null'] }
    StockItem:
      type: object
      additionalProperties: false
      required:
        [
          product_id,
          name,
          sku,
          tracked,
          stock_quantity,
          low_stock_threshold,
          effective_threshold,
          low,
          block_on_zero_stock,
          variants,
        ]
      properties:
        product_id: { type: string, format: uuid }
        name: { type: string }
        sku: { type: [string, 'null'] }
        tracked: { type: boolean }
        stock_quantity: { type: [integer, 'null'] }
        low_stock_threshold:
          {
            type: [integer, 'null'],
            description: 'Per-product threshold, or `null` = the account default.',
          }
        effective_threshold: { type: number }
        low: { type: boolean }
        block_on_zero_stock: { type: boolean }
        variants:
          type: array
          items: { $ref: '#/components/schemas/StockVariant' }
    Customer:
      type: object
      additionalProperties: false
      required:
        [
          key,
          name,
          email,
          phone,
          orders_count,
          total_spent,
          first_order_at,
          last_order_at,
          last_order_status,
        ]
      properties:
        key:
          {
            type: string,
            description: 'Lower-cased email, else phone. Stable id of the aggregate.',
          }
        name: { type: [string, 'null'] }
        email: { type: [string, 'null'] }
        phone: { type: [string, 'null'] }
        orders_count: { type: integer }
        total_spent: { type: number }
        first_order_at: { type: string, format: date-time }
        last_order_at: { type: string, format: date-time }
        last_order_status: { type: string }
    Segment:
      type: object
      additionalProperties: false
      required: [id, name, filters, customer_count, is_preset, created_at, updated_at]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        filters:
          {
            type: object,
            description: 'The saved filter object. `{}` on a membership segment (imports, webinars) — members live in the segment, not in the filters.',
          }
        customer_count: { type: [integer, 'null'] }
        is_preset: { type: boolean }
        created_at: { type: string, format: date-time }
        updated_at: { $ref: '#/components/schemas/Timestamp' }
    WebhookEvent:
      type: string
      enum:
        [
          order.created,
          order.updated,
          order.status_changed,
          order.paid,
          shipment.created,
          shipment.status_changed,
          shipment.cancelled,
          inventory.low_stock,
        ]
    Webhook:
      type: object
      additionalProperties: false
      required:
        [
          id,
          url,
          description,
          events,
          active,
          secret_prefix,
          last_triggered_at,
          last_status_code,
          dead_notified_at,
          created_at,
          updated_at,
        ]
      properties:
        id: { type: string, format: uuid }
        url: { type: string }
        description: { type: [string, 'null'] }
        events:
          type: array
          items: { $ref: '#/components/schemas/WebhookEvent' }
        active: { type: boolean }
        secret_prefix:
          {
            type: string,
            description: '`whsec_` + the first 8 characters of the secret — for recognition only.',
          }
        last_triggered_at: { $ref: '#/components/schemas/Timestamp' }
        last_status_code: { type: [integer, 'null'] }
        dead_notified_at: { $ref: '#/components/schemas/Timestamp' }
        created_at: { type: string, format: date-time }
        updated_at: { $ref: '#/components/schemas/Timestamp' }
    Delivery:
      type: object
      additionalProperties: false
      required:
        [
          id,
          webhook_id,
          event_id,
          event,
          status,
          attempt,
          next_attempt_at,
          last_status_code,
          last_error,
          delivered_at,
          created_at,
        ]
      properties:
        id: { type: string, format: uuid, description: Delivery id (`X-Velik-Delivery`). }
        webhook_id: { type: string, format: uuid }
        event_id:
          {
            type: string,
            format: uuid,
            description: The envelope `id` (`X-Velik-Event-Id`) — idempotency key.,
          }
        event: { type: string, description: 'One of the webhook events, or `test`.' }
        status: { type: string, enum: [pending, delivered, failed, dead] }
        attempt: { type: integer }
        next_attempt_at: { $ref: '#/components/schemas/Timestamp' }
        last_status_code: { type: [integer, 'null'] }
        last_error: { type: [string, 'null'] }
        delivered_at: { $ref: '#/components/schemas/Timestamp' }
        created_at: { type: string, format: date-time }
    # ── envelopes & lists ──
    OrderList:
      type: object
      additionalProperties: false
      required: [data, has_more, next_cursor]
      properties:
        data: { type: array, items: { $ref: '#/components/schemas/Order' } }
        has_more: { type: boolean }
        next_cursor: { type: [string, 'null'] }
    OrderEnvelope:
      type: object
      additionalProperties: false
      required: [data]
      properties:
        data: { $ref: '#/components/schemas/Order' }
    OrderShipmentEnvelope:
      type: object
      additionalProperties: false
      required: [data]
      properties:
        data:
          oneOf:
            - $ref: '#/components/schemas/Shipment'
            - type: 'null'
    ShipmentEnvelope:
      type: object
      additionalProperties: false
      required: [data]
      properties:
        data: { $ref: '#/components/schemas/Shipment' }
    ProductList:
      type: object
      additionalProperties: false
      required: [data, has_more, next_cursor]
      properties:
        data: { type: array, items: { $ref: '#/components/schemas/Product' } }
        has_more: { type: boolean }
        next_cursor: { type: [string, 'null'] }
    ProductEnvelope:
      type: object
      additionalProperties: false
      required: [data]
      properties:
        data: { $ref: '#/components/schemas/Product' }
    MovementList:
      type: object
      additionalProperties: false
      required: [data, has_more, next_cursor]
      properties:
        data: { type: array, items: { $ref: '#/components/schemas/Movement' } }
        has_more: { type: boolean }
        next_cursor: { type: [string, 'null'] }
    StockResponse:
      type: object
      additionalProperties: false
      required: [data, default_threshold]
      properties:
        data: { type: array, items: { $ref: '#/components/schemas/StockItem' } }
        default_threshold:
          {
            type: number,
            description: The account-wide low-stock threshold (Settings → Inventory).,
          }
    CustomerList:
      type: object
      additionalProperties: false
      required: [data, total, limit, offset]
      properties:
        data: { type: array, items: { $ref: '#/components/schemas/Customer' } }
        total: { type: integer }
        limit: { type: integer }
        offset: { type: integer }
    SegmentList:
      type: object
      additionalProperties: false
      required: [data]
      properties:
        data: { type: array, items: { $ref: '#/components/schemas/Segment' } }
    WebhookList:
      type: object
      additionalProperties: false
      required: [data, events]
      properties:
        data: { type: array, items: { $ref: '#/components/schemas/Webhook' } }
        events:
          type: array
          description: The subscribable events.
          items: { $ref: '#/components/schemas/WebhookEvent' }
    WebhookEnvelope:
      type: object
      additionalProperties: false
      required: [data]
      properties:
        data: { $ref: '#/components/schemas/Webhook' }
    WebhookCreated:
      type: object
      additionalProperties: false
      required: [data, secret]
      properties:
        data: { $ref: '#/components/schemas/Webhook' }
        secret: { type: string, description: 64 hex characters. Shown once. }
    WebhookDeleted:
      type: object
      additionalProperties: false
      required: [ok, deleted]
      properties:
        ok: { type: boolean, const: true }
        deleted: { type: string, format: uuid }
    WebhookTestResult:
      type: object
      additionalProperties: false
      required: [data, event_id]
      properties:
        data:
          oneOf:
            - $ref: '#/components/schemas/Delivery'
            - type: 'null'
        event_id: { type: string, format: uuid }
    DeliveryList:
      type: object
      additionalProperties: false
      required: [data]
      properties:
        data: { type: array, items: { $ref: '#/components/schemas/Delivery' } }
    DeliveryRetryResult:
      type: object
      additionalProperties: false
      required: [data, ok, status]
      properties:
        data:
          oneOf:
            - $ref: '#/components/schemas/Delivery'
            - type: 'null'
        ok: { type: boolean }
        status:
          { type: integer, description: HTTP status returned by your endpoint (0 = no answer). }
    # ── write requests & results ──
    OrderPatch:
      type: object
      additionalProperties: false
      minProperties: 1
      properties:
        status: { $ref: '#/components/schemas/OrderStatus' }
        tracking_number: { type: [string, 'null'], maxLength: 80 }
        tracking_url:
          {
            type: [string, 'null'],
            maxLength: 500,
            description: 'Must start with http:// or https://.',
          }
        courier:
          {
            type: [string, 'null'],
            maxLength: 40,
            description: "Slug (`econt`, `speedy`, `boxnow`) or your own courier's name.",
          }
        admin_notes: { type: [string, 'null'], maxLength: 2000 }
        notify_customer:
          {
            type: boolean,
            default: true,
            description: '`false` suppresses the "shipped" email/SMS for this transition.',
          }
    OrderPatchResult:
      type: object
      additionalProperties: false
      required: [data, status_changed, email_sent]
      properties:
        data: { $ref: '#/components/schemas/Order' }
        status_changed: { type: boolean }
        email_sent:
          {
            type: boolean,
            description: 'Whether the "shipped" email went out (customer has an email, notifications on, template not disabled).',
          }
    OrderPatchDryRun:
      type: object
      additionalProperties: false
      required: [dry_run, data, changes, status_would_change, would_notify_customer]
      properties:
        dry_run: { type: boolean, const: true }
        data: { $ref: '#/components/schemas/Order', description: The order as it is now. }
        changes: { type: object, description: The normalized fields that would be written. }
        status_would_change: { type: boolean }
        would_notify_customer: { type: boolean }
    MovementCreate:
      type: object
      additionalProperties: false
      required: [product_id, type, qty]
      properties:
        product_id: { type: string, format: uuid }
        variant_id: { type: string, format: uuid }
        type: { type: string, enum: [delivery, correction] }
        qty: { type: integer, description: 'delivery: > 0; correction: signed, ≠ 0.' }
        note: { type: string, maxLength: 500 }
        unit_cost: { type: [number, 'null'], description: Purchase price per unit (delivery only). }
    MovementResult:
      type: object
      additionalProperties: false
      required: [ok, applied, new_cost]
      properties:
        ok: { type: boolean, const: true }
        applied:
          {
            type: integer,
            description: Number of ledger rows applied (1 for a single product / variant).,
          }
        new_cost:
          {
            type: [number, 'null'],
            description: New weighted average cost when `unit_cost` was given.,
          }
    MovementDryRun:
      type: object
      additionalProperties: false
      required: [dry_run, product_id, variant_id, type, qty, unit_cost, enables_tracking]
      properties:
        dry_run: { type: boolean, const: true }
        product_id: { type: string, format: uuid }
        variant_id: { type: [string, 'null'], format: uuid }
        type: { type: string, enum: [delivery, correction] }
        qty: { type: integer }
        unit_cost: { type: [number, 'null'] }
        enables_tracking:
          {
            type: boolean,
            description: '`true` when the product does not track stock yet and this delivery would enable it.',
          }
    ShipmentCreate:
      type: object
      additionalProperties: false
      required: [order_id]
      properties:
        order_id: { type: string, format: uuid }
        courier:
          {
            type: string,
            enum: [econt, speedy, boxnow],
            description: Override the courier chosen at checkout.,
          }
        delivery_mode: { type: string, enum: [office, home] }
        office_id:
          { type: string, description: Courier office / locker id (required for `office`). }
        weight_g: { type: integer }
        dim_cm:
          type: object
          properties:
            l: { type: number }
            w: { type: number }
            h: { type: number }
        sender_origin:
          type: object
          description: Where this parcel is handed over — overrides the merchant's default for this shipment only.
          properties:
            mode: { type: string, enum: [address, office] }
            office_id: { type: string }
            office_label: { type: string }
        shipment_terms:
          type: object
          description: Cash-on-delivery / payer / open-before-pay for this shipment only.
          properties:
            cod_type: { type: string, enum: [cash, ppp] }
            service_payer: { type: string, enum: [recipient, sender] }
            open_before: { type: string, enum: [none, open, test] }
            refusal_payer: { type: string, enum: [recipient, sender] }
            econt_cd_template: { type: string }
    ShipmentCreateDryRun:
      type: object
      additionalProperties: false
      required:
        [
          dry_run,
          courier,
          delivery_mode,
          office_id,
          weight_g,
          cod_amount,
          content_label,
          shipment_terms,
        ]
      properties:
        dry_run: { type: boolean, const: true }
        courier: { type: string }
        delivery_mode: { type: string }
        office_id: { type: [string, 'null'] }
        weight_g: { type: [integer, number, 'null'] }
        cod_amount: { type: [number, 'null'] }
        content_label:
          { type: string, description: What would be printed as contents on the label. }
        shipment_terms: { type: object }
    ShipmentCancel:
      type: object
      additionalProperties: false
      properties:
        comment: { type: string, description: Reason sent to the courier (optional). }
    ShipmentCancelResult:
      type: object
      additionalProperties: false
      required: [data, courier_cancelled, courier_confirmed, order_reverted_to_processing, note]
      properties:
        data: { $ref: '#/components/schemas/Shipment' }
        courier_cancelled: { type: boolean, description: The courier accepted the cancellation. }
        courier_confirmed:
          {
            type: boolean,
            description: The courier confirmed it explicitly (Speedy/Econt; BoxNow never).,
          }
        order_reverted_to_processing: { type: boolean }
        note: { type: [string, 'null'] }
    ShipmentCancelDryRun:
      type: object
      additionalProperties: false
      required: [dry_run, data, cancellable]
      properties:
        dry_run: { type: boolean, const: true }
        data: { $ref: '#/components/schemas/Shipment' }
        cancellable:
          {
            type: boolean,
            description: '`false` when the shipment is already delivered / cancelled / refused / returned.',
          }
    WebhookCreate:
      type: object
      additionalProperties: false
      required: [url, events]
      properties:
        url:
          type: string
          maxLength: 500
          description: '`https://` only; no credentials; public host (no loopback / private / link-local / metadata / velik.ai).'
        events:
          type: array
          minItems: 1
          items: { $ref: '#/components/schemas/WebhookEvent' }
        description: { type: string, maxLength: 200 }
    WebhookPatch:
      type: object
      additionalProperties: false
      minProperties: 1
      properties:
        url: { type: string, maxLength: 500 }
        events:
          type: array
          minItems: 1
          items: { $ref: '#/components/schemas/WebhookEvent' }
        description: { type: [string, 'null'], maxLength: 200 }
        active: { type: boolean }
    # ── Б8: products:write / customers:write ──
    ProductWritable:
      type: object
      additionalProperties: false
      properties:
        name: { type: string, minLength: 1, maxLength: 200 }
        marketing_name: { type: [string, 'null'], maxLength: 200 }
        sku: { type: [string, 'null'], maxLength: 80 }
        price: { type: number, minimum: 0 }
        original_price: { type: [number, 'null'], minimum: 0 }
        active: { type: boolean }
        description: { type: [string, 'null'], maxLength: 5000 }
        delivery_type: { type: string, enum: [physical, digital] }
        weight_g: { type: [integer, 'null'], minimum: 0 }
        dim_cm:
          type: [object, 'null']
          additionalProperties: false
          properties:
            l: { type: [number, 'null'], minimum: 0 }
            w: { type: [number, 'null'], minimum: 0 }
            h: { type: [number, 'null'], minimum: 0 }
        shipping_short_name:
          {
            type: [string, 'null'],
            maxLength: 30,
            description: Printed as contents on courier labels.,
          }
        shipping_category: { type: [string, 'null'], maxLength: 40 }
        low_stock_threshold: { type: [integer, 'null'], minimum: 0 }
        block_on_zero_stock: { type: boolean }
        catalog_hidden: { type: boolean }
    ProductCreate:
      allOf:
        - $ref: '#/components/schemas/ProductWritable'
        - type: object
          required: [name, price]
    ProductPatch:
      allOf:
        - $ref: '#/components/schemas/ProductWritable'
        - type: object
          minProperties: 1
    ProductCreateDryRun:
      type: object
      additionalProperties: false
      required: [dry_run, would_create]
      properties:
        dry_run: { type: boolean, const: true }
        would_create: { type: object, description: The row that would be inserted (normalized). }
    ProductPatchDryRun:
      type: object
      additionalProperties: false
      required: [dry_run, data, changes]
      properties:
        dry_run: { type: boolean, const: true }
        data: { $ref: '#/components/schemas/Product' }
        changes: { type: object, description: The normalized columns that would be written. }
    SegmentFilters:
      type: object
      description: 'The closed filter list. Unknown keys → 400.'
      additionalProperties: false
      properties:
        minTotal: { type: number }
        maxTotal: { type: number }
        minOrders: { type: number }
        maxOrders: { type: number }
        lastOrderBeforeDaysAgo: { type: number }
        lastOrderAfterDate: { type: string }
        lastOrderBeforeDate: { type: string }
        productId: { type: string }
        pageId: { type: string }
        inSegment: { type: string }
        hasEmail: { type: boolean }
        hasPhone: { type: boolean }
        hasAbandonedCart: { type: boolean }
    SegmentCreate:
      type: object
      additionalProperties: false
      required: [name]
      properties:
        name: { type: string, minLength: 1, maxLength: 120 }
        filters: { $ref: '#/components/schemas/SegmentFilters' }
    SegmentPatch:
      type: object
      additionalProperties: false
      minProperties: 1
      properties:
        name: { type: string, minLength: 1, maxLength: 120 }
        filters: { $ref: '#/components/schemas/SegmentFilters' }
    SegmentEnvelope:
      type: object
      additionalProperties: false
      required: [data]
      properties:
        data: { $ref: '#/components/schemas/Segment' }
    SegmentCreated:
      type: object
      additionalProperties: false
      required: [data, membership]
      properties:
        data: { $ref: '#/components/schemas/Segment' }
        membership:
          {
            type: boolean,
            description: '`true` = fill it with POST …/members; `false` = computed from filters.',
          }
    SegmentCreateDryRun:
      type: object
      additionalProperties: false
      required: [dry_run, would_create]
      properties:
        dry_run: { type: boolean, const: true }
        would_create:
          type: object
          additionalProperties: false
          required: [name, filters, membership]
          properties:
            name: { type: string }
            filters: { type: object }
            membership: { type: boolean }
    SegmentPatchDryRun:
      type: object
      additionalProperties: false
      required: [dry_run, data, changes]
      properties:
        dry_run: { type: boolean, const: true }
        data: { $ref: '#/components/schemas/Segment' }
        changes: { type: object }
    SegmentDeleted:
      type: object
      additionalProperties: false
      required: [ok, deleted]
      properties:
        ok: { type: boolean, const: true }
        deleted: { type: string, format: uuid }
    SegmentDeleteDryRun:
      type: object
      additionalProperties: false
      required: [dry_run, data, deletable]
      properties:
        dry_run: { type: boolean, const: true }
        data: { $ref: '#/components/schemas/Segment' }
        deletable: { type: boolean }
    SegmentMembersBody:
      type: object
      additionalProperties: false
      required: [customers]
      properties:
        customers:
          type: array
          minItems: 1
          maxItems: 500
          items:
            type: object
            additionalProperties: false
            properties:
              email: { type: string }
              phone: { type: string }
    SegmentMembersAdded:
      type: object
      additionalProperties: false
      required: [segment_id, added, skipped]
      properties:
        segment_id: { type: string, format: uuid }
        added: { type: integer }
        skipped: { type: integer, description: Already members. }
    SegmentMembersRemoved:
      type: object
      additionalProperties: false
      required: [segment_id, removed]
      properties:
        segment_id: { type: string, format: uuid }
        removed: { type: integer }
    SegmentMembersDryRun:
      type: object
      additionalProperties: false
      required: [dry_run, segment_id, customers]
      properties:
        dry_run: { type: boolean, const: true }
        segment_id: { type: string, format: uuid }
        customers:
          { type: array, items: { type: string }, description: The normalized customer keys. }
    CustomerNoteCreate:
      type: object
      additionalProperties: false
      required: [text]
      properties:
        text: { type: string, minLength: 1, maxLength: 2000 }
    CustomerNoteCreated:
      type: object
      additionalProperties: false
      required: [data]
      properties:
        data:
          type: object
          additionalProperties: false
          required: [id, customer_key, text, created_at]
          properties:
            id: { type: string, format: uuid }
            customer_key: { type: string }
            text: { type: string }
            created_at: { type: string, format: date-time }
    CustomerNoteDryRun:
      type: object
      additionalProperties: false
      required: [dry_run, customer_key, text]
      properties:
        dry_run: { type: boolean, const: true }
        customer_key: { type: string }
        text: { type: string }
    # ── webhook envelopes (what we POST to you) ──
    WebhookEnvelopeBase:
      type: object
      required: [id, event, created_at, account_id, api_version, data]
      properties:
        id:
          {
            type: string,
            format: uuid,
            description: Event id — the idempotency key. The same id may arrive more than once (at-least-once).,
          }
        event: { type: string }
        created_at: { type: string, format: date-time }
        account_id: { type: string, format: uuid, description: The merchant account (user id). }
        api_version: { type: string, const: '2026-09-01' }
        data: { type: object }
    WebhookEnvelopeOrderCreated:
      allOf:
        - $ref: '#/components/schemas/WebhookEnvelopeBase'
        - type: object
          properties:
            event: { const: order.created }
            data:
              type: object
              additionalProperties: false
              required: [order]
              properties:
                order: { $ref: '#/components/schemas/Order' }
    WebhookEnvelopeOrderUpdated:
      allOf:
        - $ref: '#/components/schemas/WebhookEnvelopeBase'
        - type: object
          properties:
            event: { const: order.updated }
            data:
              type: object
              additionalProperties: false
              required: [order, changed, source]
              properties:
                order: { $ref: '#/components/schemas/Order' }
                changed:
                  {
                    type: array,
                    items: { type: string },
                    description: The order columns that changed.,
                  }
                source: { $ref: '#/components/schemas/EventSource' }
    WebhookEnvelopeOrderStatusChanged:
      allOf:
        - $ref: '#/components/schemas/WebhookEnvelopeBase'
        - type: object
          properties:
            event: { const: order.status_changed }
            data:
              type: object
              additionalProperties: false
              required: [order, previous_status, status, source]
              properties:
                order: { $ref: '#/components/schemas/Order' }
                previous_status: { type: [string, 'null'] }
                status: { $ref: '#/components/schemas/OrderStatus' }
                source: { $ref: '#/components/schemas/EventSource' }
    WebhookEnvelopeOrderPaid:
      allOf:
        - $ref: '#/components/schemas/WebhookEnvelopeBase'
        - type: object
          properties:
            event: { const: order.paid }
            data:
              type: object
              additionalProperties: false
              required: [order, previous_payment_status, source]
              properties:
                order: { $ref: '#/components/schemas/Order' }
                previous_payment_status: { type: [string, 'null'] }
                source: { $ref: '#/components/schemas/EventSource' }
    WebhookEnvelopeShipmentCreated:
      allOf:
        - $ref: '#/components/schemas/WebhookEnvelopeBase'
        - type: object
          properties:
            event: { const: shipment.created }
            data:
              type: object
              additionalProperties: false
              required: [shipment, order_id, source]
              properties:
                shipment: { $ref: '#/components/schemas/Shipment' }
                order_id: { type: string, format: uuid }
                source: { $ref: '#/components/schemas/EventSource' }
    WebhookEnvelopeShipmentStatusChanged:
      allOf:
        - $ref: '#/components/schemas/WebhookEnvelopeBase'
        - type: object
          properties:
            event: { const: shipment.status_changed }
            data:
              type: object
              additionalProperties: false
              required: [shipment, previous_status, status, event, source]
              properties:
                shipment: { $ref: '#/components/schemas/Shipment' }
                previous_status: { type: [string, 'null'] }
                status: { $ref: '#/components/schemas/ShipmentStatus' }
                event:
                  description: The courier event that caused the change (`{ at, code, label }`), or `null`.
                  oneOf:
                    - $ref: '#/components/schemas/StatusEvent'
                    - type: 'null'
                source: { $ref: '#/components/schemas/EventSource' }
    WebhookEnvelopeShipmentCancelled:
      allOf:
        - $ref: '#/components/schemas/WebhookEnvelopeBase'
        - type: object
          properties:
            event: { const: shipment.cancelled }
            data:
              type: object
              additionalProperties: false
              required: [shipment, order_id, courier_cancelled, courier_confirmed, source]
              properties:
                shipment: { $ref: '#/components/schemas/Shipment' }
                order_id: { type: string, format: uuid }
                courier_cancelled: { type: boolean }
                courier_confirmed: { type: boolean }
                source: { $ref: '#/components/schemas/EventSource' }
    WebhookEnvelopeLowStock:
      allOf:
        - $ref: '#/components/schemas/WebhookEnvelopeBase'
        - type: object
          properties:
            event: { const: inventory.low_stock }
            data:
              type: object
              additionalProperties: false
              required: [product, stock_quantity, threshold]
              properties:
                product:
                  type: object
                  additionalProperties: false
                  required: [id, name, sku]
                  properties:
                    id: { type: string, format: uuid }
                    name: { type: string }
                    sku: { type: [string, 'null'] }
                stock_quantity: { type: integer }
                threshold: { type: number }
    WebhookEnvelopeTest:
      allOf:
        - $ref: '#/components/schemas/WebhookEnvelopeBase'
        - type: object
          properties:
            event: { const: test }
            data:
              type: object
              additionalProperties: false
              required: [message, webhook_id]
              properties:
                message: { type: string }
                webhook_id: { type: string, format: uuid }
    EventSource:
      type: string
      description: Who caused the change — the merchant in the admin, an API key, the courier (status mirror), a payment, or an automation.
      enum: [merchant, api, courier, payment, automation]
