openapi: 3.1.0
info:
  title: Lenful Seller API
  version: "1.0.0"
  summary: Integration API for sellers — orders, catalog, designs, tracking and wallet.
  description: |
    # Lenful Seller API

    > **Status — rolling out (Phase 4).** This document is the contract for the machine-to-machine
    > integration API (module M20).
    >
    > **Available now**
    >
    > - `POST /orders` — create an order
    > - `GET /orders/{orderId}` — get one order
    > - `GET /stores` — list your stores
    > - `GET /products` — list catalog products (backward-compatible)
    > - `POST /products/list` — filtered catalog list via JSON body (recommended for extensions)
    > - `GET /products/{productId}/variants` — list a product's variants
    > - `GET /designs` — list your designs
    > - `GET /orders` — list orders
    > - `PATCH /orders/{orderId}` — edit an order
    > - `POST /orders/{orderId}/cancel` — cancel items
    > - `GET /orders/{orderId}/trackings` — tracking of an order
    > - `GET /wallet` — wallet balance
    > - `GET /wallet/ledger` — wallet ledger
    >
    > There is no sandbox — requests act on your real account and wallet.

    The API lets a seller's own server (custom store, in-house ERP) automate the public seller workflows:
    **create/edit/cancel orders**, **read the catalog**, **read the design library**, and **read stores, tracking and
    wallet data**. It has **no business logic of its own** — every request goes through the same services as the
    seller portal, so money, status and permission rules stay identical.

    ## Authentication

    Authenticate every request with an **API key** sent as a Bearer token:

    ```
    Authorization: Bearer lf_live_xxxxxxxxxxxx
    ```

    - Keys are created by the **account owner** in **Settings → API keys**. Staff and admins cannot create
      keys on a seller's behalf.
    - A key acts with the permissions of the owner who created it. If that person is no longer allowed to
      create orders, order requests answer `403` — create a new key from the current owner account.
    - A key is shown **once** at creation and stored only as a hash — Lenful can never show it again. Lost
      key ⇒ create a new one.
    - Keys carry **scopes** (see below). A request outside a key's scopes returns `403`.
    - **Never** put the key in the URL / query string (`?api_key=`). Keys in URLs leak into access logs —
      such a request is treated as unauthenticated (`401`).
    - The Bearer header is the **only** credential. No cookies, no CSRF token, no login session — the
      API lives under `/api/public/v1/…` and ignores browser sessions entirely.

    ## Scopes (least privilege)

    Scopes are an allow-list; a scope not granted to your key does not exist for that key.

    | Scope | Grants |
    |---|---|
    | `orders:read` | View your orders, items and status |
    | `orders:write` | Create orders; edit orders while `pending` |
    | `orders:cancel` | Cancel items (issues real refunds) — kept **separate** from `orders:write` |
    | `products:read` | Read the Lenful catalog: products, variants, list price |
    | `designs:read` | Read your design library (artwork, SKU, print positions) |
    | `stores:read` | List your stores (id, name, platform, default flag) — to pick `store_id` when creating orders |
    | `tracking:read` | Read tracking numbers + status for your orders |
    | `wallet:read` | Read wallet balance and ledger |

    There is **no scope** for supplier data or unit cost — that information is never exposed by any
    endpoint, embedded or otherwise.

    ## Rate limits

    Limits are counted **per seller** (not per key — multiple keys share one budget), over a 60-second window:

    | Requests | Limit |
    |---|---|
    | Reads (`GET`, plus read-only `POST /products/list`) | 60 / minute per seller |
    | Writes (`POST`, `PATCH`, except `POST /products/list`) | 20 / minute per seller |
    | Invalid or missing key (`401`) | 10 / minute per IP address |

    Responses carry `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`; a `429` adds
    `Retry-After` (seconds).

    ## Errors

    Every response uses the envelope `{ success, message, data }`.

    | Status | Meaning | What to do |
    |---|---|---|
    | `400` | The body does not match the schema — wrong type, unknown field, `designs: []`, more than 10 `mockup_urls`, a URL that is not `http(s)` | Fix the request shape; `data` holds the field errors |
    | `401` | Missing, invalid, revoked or expired key | Check the key; create a new one if needed |
    | `403` | Key lacks the scope, account suspended, or the key owner may not perform the action (create / edit / cancel orders) | Read `message` |
    | `404` | Not found — or it belongs to another seller | — |
    | `409` | Create: same `external_order_id` already exists in that store (`data` = the existing order), or an identical request is still being processed. Edit / cancel: the order's state does not allow it now (items in production, already cancelled, edit limit reached) | Create: treat as success for retries; do not resend with a new id. Edit / cancel: read `message` |
    | `422` | A business rule rejected the order (unknown SKU, wrong print position, no shipping rate, …) | `data.errors[]` lists every problem as `{ field, message }`, e.g. `line_items[1].designs[0].position` |
    | `429` | Rate limit exceeded | Wait `Retry-After` seconds |

    A rejected order (`400`/`403`/`409`/`422`) creates **nothing** — no order, no wallet charge, no design.

    ## Versioning

    The version lives in the path (`/api/public/v1/…`). Within a version, no breaking changes are made. A
    deprecated version is announced ≥ 6 months ahead and answers `410 Gone` (never `404`) after sunset.
  contact:
    name: Lenful support
  license:
    name: Proprietary

servers:
  - url: https://seller-v8.lenful.com/api/public/v1
    description: Production

security:
  - apiKey: []

tags:
  - name: Orders
    description: Create and read orders.
  - name: Tracking
    description: Read shipment tracking.
  - name: Products
    description: Read the Lenful catalog.
  - name: Designs
    description: Read your design library.
  - name: Wallet
    description: Read wallet balance and ledger.
  - name: Stores
    description: List your stores — pick `store_id` when creating an order.

paths:
  /orders:
    post:
      tags: [Orders]
      summary: Create an order
      operationId: createOrder
      description: |
        Create one Lenful order from your store. Requires scope `orders:write`.

        - **`external_order_id` is required** (missing ⇒ `422`). It is your own order id and the idempotency key
          **per store**: the same `external_order_id` sent twice to the same store returns `409` with the
          already-created order (safe to retry). If two identical requests carrying `designs` arrive at the same
          time, one is processed and the other gets `409`.
        - `store_id` (from `GET /stores`) is optional — omit it to use your default store.
        - `shipping_method_code` is optional — omit it to use the default method; the codes are listed in the seller portal under **Orders → Import** (the same codes as the import file).
          Methods with `requires_customer_label` (Ship By Platform) need `label_url` — one label for the whole
          order — and only `shipping_address.name`; other methods need a full address and reject `label_url`.
          Send `label_url` without `shipping_method_code` and the order goes to the Ship By Platform method
          automatically; send it together with a method that does not take a label and the order is rejected
          (`422`, the message names the codes that do) — the method you asked for is never swapped for you.
        - `seller_id` is taken from your API key. If you send one in the body it is ignored.
        - Payment follows your wallet settings. If the wallet is short the order is still created as
          `unpaid` and charged automatically when you top up — this is a `201`, not an error.
        - **Dropship lines** send only `sku` + `quantity`. **POD lines** attach artwork either by
          `design_sku` (a design already in your library) **or** by `designs` (print file per print
          position) + `mockup_urls` — never both. A POD line without a design is still created at
          `pending`, but its item cannot move into production until a design is attached. See `LineItem`.
      security:
        - apiKey: [orders:write]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateOrderRequest"
            examples:
              podAndDropship:
                summary: One dropship line, one POD line with per-position files, one POD line reusing a design
                value:
                  external_order_id: "SHOP-10023"
                  store_id: "9a7b3c1e-2d4f-4a6b-8c9d-0e1f2a3b4c5d"
                  shipping_method_code: 0
                  shipping_address:
                    name: "John Doe"
                    line1: "123 Main St"
                    line2: "Apt 4"
                    city: "New York"
                    state_code: "NY"
                    postal: "10001"
                    country_code: "US"
                    phone: "+1 555 0100"
                  line_items:
                    - sku: "TSHIRT-BLK-L"
                      quantity: 2
                    - sku: "LF-USTSHIRT-WHITEL"
                      quantity: 1
                      designs:
                        - position: "front"
                          design_url: "https://cdn.example.com/cat-front.png"
                        - position: "back"
                          design_url: "https://cdn.example.com/cat-back.png"
                      mockup_urls:
                        - "https://cdn.example.com/cat-mockup-front.jpg"
                        - "https://cdn.example.com/cat-mockup-back.jpg"
                    - sku: "LF-USHOODIE-BLACKM"
                      quantity: 3
                      design_sku: "LF-USHOODIE-A3K9QP"
                    - sku: "LF-USTSHIRT-BLACKL"
                      quantity: 1
                      clone_design: true
                      mockup_urls:
                        - "https://cdn.example.com/reference-to-clone.jpg"
                      note: "Clone this exactly, change the text to Lenful"
              shipByPlatform:
                summary: Ship By Platform — one label for the whole order, name only
                value:
                  external_order_id: "TT-5582913"
                  shipping_method_code: 1
                  label_url: "https://labels.example.com/tiktok/5582913.pdf"
                  shipping_address:
                    name: "Jane Roe"
                  line_items:
                    - sku: "LF-USTSHIRT-WHITEL"
                      quantity: 1
                      design_sku: "LF-USTSHIRT-Q7M2XD"
                    - sku: "TSHIRT-BLK-L"
                      quantity: 1
      responses:
        "201":
          description: Order created (may be `unpaid` if the wallet was short).
          headers:
            X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OrderResponse"
              examples:
                created:
                  value:
                    success: true
                    message: "Order created"
                    data:
                      id: "b0f1…"
                      code: "LF-2026-000123"
                      external_order_id: "SHOP-10023"
                      workflow_status: "pending"
                      payment_status: "paid"
                      revenue_total: 10500
                      currency: "USD"
                      created_at: "2026-08-29T09:00:00Z"
                      items:
                        - id: "i1…"
                          sku: "TSHIRT-BLK-L"
                          quantity: 2
                          workflow_status: "pending"
                          item_revenue: 4000
                          design_sku: null
                        - id: "i2…"
                          sku: "LF-USTSHIRT-WHITEL"
                          quantity: 1
                          workflow_status: "pending"
                          item_revenue: 2000
                          design_sku: "LF-USTSHIRT-Q7M2XD"
                        - id: "i3…"
                          sku: "LF-USHOODIE-BLACKM"
                          quantity: 3
                          workflow_status: "pending"
                          item_revenue: 4500
                          design_sku: "LF-USHOODIE-A3K9QP"
        "409":
          description: |
            An order with this `external_order_id` already exists in the store — returns the existing order. Also
            returned when an identical request with `designs` is still being processed; retry shortly.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OrderResponse" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
    get:
      tags: [Orders]
      summary: List orders
      operationId: listOrders
      description: |
        List your orders, newest first — every order of your account, whatever created it (API, seller portal,
        file import, marketplace sync). Requires scope `orders:read`. Paginated; `limit` caps at 100. Each order
        includes its items, like `GET /orders/{orderId}`.

        Date filters use **Vietnam time (UTC+7)** and are inclusive: `created_to=2026-09-30` includes the whole day.
        A `store_id` that is not yours returns an empty page.
      security:
        - apiKey: [orders:read]
      parameters:
        - { $ref: "#/components/parameters/Page" }
        - { $ref: "#/components/parameters/Limit" }
        - name: workflow_status
          in: query
          description: Filter by order workflow status.
          schema: { $ref: "#/components/schemas/WorkflowStatus" }
        - name: payment_status
          in: query
          schema: { type: string, enum: [unpaid, paid, partially_refunded, refunded] }
        - name: store_id
          in: query
          description: Only orders of this store (from `GET /stores`).
          schema: { type: string, format: uuid }
        - name: created_from
          in: query
          description: Created on or after this day (YYYY-MM-DD, Vietnam time).
          schema: { type: string, format: date, example: "2026-09-01" }
        - name: created_to
          in: query
          description: Created on or before this day (YYYY-MM-DD, Vietnam time, inclusive). Must not be before `created_from`.
          schema: { type: string, format: date, example: "2026-09-30" }
      responses:
        "200":
          description: A page of orders.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OrderListResponse" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/TooManyRequests" }

  /orders/{orderId}:
    parameters:
      - { $ref: "#/components/parameters/OrderId" }
    get:
      tags: [Orders]
      summary: Get one order
      operationId: getOrder
      description: |
        Read one order of yours by id. Requires scope `orders:read`. An order that belongs to another
        seller returns `404` (never `403` — a `403` would confirm it exists).
      security:
        - apiKey: [orders:read]
      responses:
        "200":
          description: The order.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OrderResponse" }
        "404": { $ref: "#/components/responses/NotFound" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    patch:
      tags: [Orders]
      summary: Edit an order (while every item is pending)
      operationId: updateOrder
      description: |
        Edit an order while **every item is still `pending`** (or `updating`; cancelled items are ignored, but an order whose items are all cancelled cannot be edited). Requires scope `orders:write`.
        Send only what you want to change — omitted fields stay as they are. `store_id` and `external_order_id` cannot change.

        **Lines.** `line_items`, when sent, is the **full list** of lines after the edit:

        - a line with `id` (from `OrderItem.id`) edits that line;
        - a line without `id` is a new line;
        - a current line missing from the list is removed.

        Design fields follow the same rules as create (`LineItem`), and each line is sent in full: a line without any
        design field ends up **without a design**, and a line without `note` ends up without a note. `clone_design` cannot
        start a new design hire on an edit — a line whose hire is in progress keeps it (send its `id`, no design fields).

        **Money.** The order is re-priced; the difference is charged to or refunded to your wallet. If the wallet cannot
        cover an increase, the order goes back to `unpaid` and the message says the payment is still outstanding.

        **Limits.** At most **3 edits per order**, and **2 minutes** between two edits (shared with the seller portal).
      security:
        - apiKey: [orders:write]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/UpdateOrderRequest" }
            examples:
              address:
                summary: Change the shipping address only
                value:
                  shipping_address:
                    name: "Jane Doe"
                    line1: "200 Oak Ave"
                    city: "Austin"
                    state_code: "TX"
                    postal: "73301"
                    country_code: "US"
              lines:
                summary: Keep one line with a new quantity, add a dropship line (other lines are removed)
                value:
                  line_items:
                    - id: "3f1c2a9e-7b4d-4c1a-9e2f-5a6b7c8d9e0f"
                      sku: "TEE-BLK-L"
                      quantity: 3
                      design_sku: "LF-TEE-A1B2C3"
                    - sku: "MUG-11OZ"
                      quantity: 1
      responses:
        "200":
          description: Updated order.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OrderResponse" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: |
            The order cannot be edited right now: an item is already in production, every item is cancelled, a line you left
            out has already been charged (cancel it instead), the order reached 3 edits, it was edited less than 2 minutes
            ago, or a request with `designs` for this order is still being processed.
        "422": { $ref: "#/components/responses/UnprocessableEntity" }
        "429": { $ref: "#/components/responses/TooManyRequests" }

  /orders/{orderId}/cancel:
    parameters:
      - { $ref: "#/components/parameters/OrderId" }
    post:
      tags: [Orders]
      summary: Cancel items
      operationId: cancelOrder
      description: |
        Cancel every item you are allowed to cancel — items still `pending`; items already in production are kept
        as they are. Each cancelled item is refunded to your wallet. Requires the separate scope `orders:cancel`.
        A `reason` is required: it is recorded in the item history. The response is the order after the call; the
        message says how many items were cancelled and the refunded amount (US cents). If an item fails midway it
        stays `pending` — call again to retry.
      security:
        - apiKey: [orders:cancel]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CancelOrderRequest" }
            example:
              reason: "Customer cancelled on our store"
      responses:
        "200":
          description: The order after cancellation.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OrderResponse" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: No item of this order can be cancelled by you — every remaining item is in production or already cancelled.
        "422": { $ref: "#/components/responses/UnprocessableEntity" }
        "429": { $ref: "#/components/responses/TooManyRequests" }

  /orders/{orderId}/trackings:
    parameters:
      - { $ref: "#/components/parameters/OrderId" }
    get:
      tags: [Tracking]
      summary: Get tracking for an order
      operationId: getOrderTrackings
      description: |
        Tracking numbers + status for the items of one order. Requires scope `tracking:read`. An item can
        carry several numbers over its life; the current one is flagged `is_current`.
      security:
        - apiKey: [tracking:read]
      responses:
        "200":
          description: Tracking entries.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TrackingListResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }

  /stores:
    get:
      tags: [Stores]
      summary: List your stores
      operationId: listStores
      description: |
        Your stores, to choose `store_id` when creating an order. Requires scope `stores:read`. Read-only —
        stores are created, connected and disconnected by a person in the seller portal, never through the API.
        Store credentials are never returned.
      security:
        - apiKey: [stores:read]
      responses:
        "200":
          description: All your stores (a seller has few; not paginated).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/StoreListResponse" }
              examples:
                twoStores:
                  value:
                    success: true
                    message: "ok"
                    data:
                      items:
                        - { id: "5c1e…", name: "Default store", platform: "manual", is_default: true, status: "connected" }
                        - { id: "9a7b…", name: "My Shopify", platform: "shopify", is_default: false, status: "connected" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/TooManyRequests" }

  /products:
    get:
      tags: [Products]
      summary: List catalog products (GET compatibility)
      operationId: listProducts
      description: Backward-compatible GET catalog list, optionally filter by product name or variant SKU using query `q`. For extensions, prefer POST /products/list with JSON filters. Requires `products:read`. Returns product identity/type/print positions only; fetch variants separately for SKU and list price. Supplier data and unit costs are never returned.
      security:
        - apiKey: [products:read]
      parameters:
        - { $ref: "#/components/parameters/Page" }
        - { $ref: "#/components/parameters/Limit" }
        - name: q
          in: query
          required: false
          description: Case-insensitive partial search on product name or variant SKU. Blank returns the regular catalog.
          schema: { type: string, maxLength: 200 }
      responses:
        "200":
          description: A page of products.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ProductListResponse" }

  /products/list:
    post:
      tags: [Products]
      summary: List filtered catalog products (extension)
      operationId: postListProducts
      description: Read-only catalog list. Send `q` (product name or variant SKU, case-insensitive substring) and/or `type` (`pod`/`dropship`) in a JSON body; omit both for the regular catalog. Pagination uses `page` and `limit` (max 100) in the query. Authenticates with Bearer API key, scope `products:read`; portal-only POST `/api/v1/products/list` instead requires a browser session and is not accessible with an API key. Only active products are returned and product variants must be fetched separately. This read-only POST consumes the 60/minute READ rate bucket rather than the 20/minute WRITE bucket.
      security:
        - apiKey: [products:read]
      parameters:
        - { $ref: "#/components/parameters/Page" }
        - { $ref: "#/components/parameters/Limit" }
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                q: { type: string, maxLength: 200, description: "Substring of product name or variant SKU" }
                type: { type: string, enum: [pod, dropship] }
            examples:
              dropship:
                summary: Dropship products
                value: { type: dropship }
              keyword:
                summary: Find product by name or SKU
                value: { q: "hoodie" }
      responses:
        "200":
          description: A page of active catalog products.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ProductListResponse" }

        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422":
          description: Invalid page, limit, q or type filter.
        "429": { $ref: "#/components/responses/TooManyRequests" }

  /products/{productId}/variants:
    parameters:
      - name: productId
        in: path
        required: true
        schema: { type: string, format: uuid }
    get:
      tags: [Products]
      summary: List a product's variants
      operationId: listVariants
      description: Variants (the sellable unit) with `sku`, `list_price`, `attributes`. Requires scope `products:read`.
      security:
        - apiKey: [products:read]
      responses:
        "200":
          description: Variants.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/VariantListResponse" }

  /designs:
    get:
      tags: [Designs]
      summary: List your designs
      operationId: listDesigns
      description: >
        Your design library (artwork you uploaded). Requires scope `designs:read`. Each design carries its readable
        `sku` plus `print_areas` (`position` + `design_url` + size). For normal print positions, a non-empty
        `position`/`design_url` pair can be reused in `line_items[].designs` when creating an order. For embroidery-only
        or `full_ds` designs, use `design_sku` instead; the order API only accepts regular print-file URLs in
        `line_items[].designs`. Supplier blueprints and internal file metadata are never exposed.
      security:
        - apiKey: [designs:read]
      parameters:
        - { $ref: "#/components/parameters/Page" }
        - { $ref: "#/components/parameters/Limit" }
        - name: product_id
          in: query
          description: Only designs attached to this product.
          schema: { type: string, format: uuid }
        - name: q
          in: query
          description: Case-insensitive contains search by design name, design SKU or product name.
          schema: { type: string, minLength: 1, maxLength: 200 }
      responses:
        "200":
          description: A page of designs.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DesignListResponse" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/TooManyRequests" }

  /wallet:
    get:
      tags: [Wallet]
      summary: Get wallet balance
      operationId: getWallet
      description: Your wallet balance and currency. Requires scope `wallet:read`.
      security:
        - apiKey: [wallet:read]
      responses:
        "200":
          description: Wallet.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WalletResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/TooManyRequests" }

  /wallet/ledger:
    get:
      tags: [Wallet]
      summary: List wallet ledger
      operationId: listWalletLedger
      description: Append-only wallet ledger entries, newest first. Requires scope `wallet:read`. Paginated.
      security:
        - apiKey: [wallet:read]
      parameters:
        - { $ref: "#/components/parameters/Page" }
        - { $ref: "#/components/parameters/Limit" }
      responses:
        "200":
          description: A page of ledger entries.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WalletLedgerResponse" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/TooManyRequests" }

components:
  securitySchemes:
    apiKey:
      type: http
      scheme: bearer
      bearerFormat: "lf_live_… (API key)"
      description: |
        Send your API key as a Bearer token: `Authorization: Bearer lf_live_…`. Scopes on the key gate
        each endpoint.

  parameters:
    Page:
      name: page
      in: query
      description: 1-based page number.
      schema: { type: integer, minimum: 1, default: 1 }
    Limit:
      name: limit
      in: query
      description: Page size. Capped at 100.
      schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
    OrderId:
      name: orderId
      in: path
      required: true
      description: Lenful order id (uuid).
      schema: { type: string, format: uuid }

  headers:
    RateLimitRemaining:
      description: Requests left in the current window for your seller.
      schema: { type: integer }

  responses:
    BadRequest:
      description: |
        The body is malformed or does not match the schema (wrong type, unknown field, `designs: []`, more than 10
        `mockup_urls`, a value that is not a URL). Cross-field and business rules answer `422` instead.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Unauthorized:
      description: Missing or invalid API key.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            missing: { value: { success: false, message: "Missing API key" } }
    Forbidden:
      description: Authenticated, but the key lacks the required scope (or the account is suspended).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            scope: { value: { success: false, message: "Key is missing scope orders:write" } }
    NotFound:
      description: Not found (or not yours).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    UnprocessableEntity:
      description: A business rule rejected the request (e.g. no shipping rate for the destination).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    TooManyRequests:
      description: Rate limit exceeded for your seller. Retry after `Retry-After` seconds.
      headers:
        Retry-After:
          description: Seconds to wait before retrying.
          schema: { type: integer }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }

  schemas:
    WorkflowStatus:
      type: string
      description: Item/order workflow status. Order status is derived from its items.
      enum: [pending, picked, processing, fulfillment, updating, hold, resend, cancel]

    Money:
      type: integer
      format: int64
      description: Amount in **US cents** (integer). E.g. `6000` = $60.00.
      example: 6000

    ShippingAddress:
      type: object
      description: |
        For a normal shipping method, `name` (the customer name), `line1`, `city` and `country_code` are required,
        and `state_code` for countries that use states → otherwise `422`.
        For a method with `requires_customer_label` (e.g. Ship By Platform) the customer and address are printed on
        your label, so `name` and the address may be left empty. Shipping is priced by country: send `country_code` if you
        know it — when it is omitted, `US` is used (and saved on the order).
      properties:
        name: { type: string }
        line1: { type: string }
        line2: { type: [string, "null"] }
        city: { type: string }
        state_code: { type: [string, "null"] }
        postal: { type: [string, "null"] }
        country_code: { type: string, description: "ISO 3166-1 alpha-2", example: "US" }
        phone: { type: [string, "null"] }

    DesignFile:
      type: object
      required: [position, design_url]
      properties:
        position:
          type: string
          description: "Print position code — one of the product's `print_positions[].code` (see `GET /products`). Each position at most once per line."
          example: "front"
        design_url:
          type: string
          format: uri
          description: "http(s) URL of the print file for this position."
          example: "https://cdn.example.com/cat-front.png"

    LineItem:
      type: object
      required: [sku, quantity]
      description: |
        One order line.

        **Dropship** lines send only `sku` + `quantity`. Sending any design field on a dropship line → `422`.

        A **POD** line attaches its artwork in exactly **one** of two ways (sending both → `422`):
        - `design_sku` — reuse a design already in your Lenful library. Its print files and mockups are used as-is.
          Unknown, deleted or not yours → `422` (one message for all three); a design of a different product than `sku` → `422`.
        - `designs` + `mockup_urls` — send a print file per print position. Lenful creates a new design in your library
          (named `API <external_order_id> · <sku>`) and returns its `design_sku` on the order item.
          `designs` **must include the product's primary position** (`print_positions[].is_primary`) → otherwise `422`.
          `mockup_urls` sent alone (without `designs`) → `422`. External links are stored as given (not downloaded
          or checked) — keep them reachable until the item ships. Files hosted on Lenful storage must pass the file check → otherwise `422`.

        A POD line with no design is accepted (`201`); the item stays `pending` and cannot enter production until a design is attached.

        Only print files can be sent here. Embroidery files, supplier blueprints and the `full_ds` position are not accepted
        through the API — create such a design in the seller portal, then send its `design_sku`.

        **Hire a designer to clone (`clone_design: true`, POD only).** Lenful's design team redraws the artwork from your
        references and adds the design-hire surcharge to the line (`alloc_surcharge`). References come from the line:
        `design_sku` (that design's print files + mockups), `designs` + `mockup_urls`, or `mockup_urls` alone — at least
        one is required (`422` otherwise). With `design_sku` or `designs`, the delivered artwork **replaces that design's
        files** (so other orders still using that design and not yet in production get the new files too); with
        `mockup_urls` alone a new design is created. The line's `design_sku` stays `null` until you approve the
        delivered artwork. Products without design-hire pricing → `422`; dropship lines → `422`.
      properties:
        sku: { type: string, description: "Lenful catalog SKU (variant)." }
        quantity: { type: integer, minimum: 1 }
        design_sku:
          type: string
          description: "POD only — SKU of an existing design in your library. Mutually exclusive with `designs` / `mockup_urls`."
          example: "LF-USHOODIE-A3K9QP"
        designs:
          type: array
          minItems: 1
          items: { $ref: "#/components/schemas/DesignFile" }
          description: |
            POD only — one print file per print position. **Order matters:** the first entry is the main print file.
            **Pricing:** every position after the first adds an extra print-area surcharge × `quantity`.
            Unknown or repeated `position` → `422`. Requires `mockup_urls`.
        mockup_urls:
          type: array
          minItems: 1
          maxItems: 10
          items: { type: string, format: uri }
          description: "POD only, required with `designs` — mockup images of the whole item (1–10). The first is the main mockup."
        clone_design:
          type: boolean
          description: "POD only — `true` hires Lenful's design team to clone the artwork from this line's references (see above). `false` or omitted: no hire."
        note:
          type: [string, "null"]
          maxLength: 2000
          description: "Note for this item (shown to Lenful staff). With `clone_design: true` it is also the brief sent to the designer."

    CreateOrderRequest:
      type: object
      required: [external_order_id, shipping_address, line_items]
      description: |
        Required: `external_order_id`, `shipping_address`, `line_items`. Optional: `store_id` (default store when
        omitted), `shipping_method_code` (default method when omitted), `label_url` (only for Ship By Platform methods).
      properties:
        external_order_id:
          type: string
          description: "Your order id. Required — missing ⇒ 422 (not 400). Idempotency key per store: same value twice ⇒ 409 with the existing order."
          example: "SHOP-10023"
        store_id:
          type: string
          format: uuid
          description: |
            Which of your stores the order belongs to (from `GET /stores`). Omit it to use your **default store**.
            A store that is not yours or does not exist → `422`. Idempotency is per store: the same
            `external_order_id` in two different stores creates two orders.
        shipping_method_code:
          type: integer
          minimum: 0
          description: |
            Shipping method `code` — the codes are listed in the seller portal under **Orders → Import** (the same codes as the import file). Omit it to use the default method. Unknown or
            inactive code → `422`; no rate for this destination/product → `422`.
        label_url:
          type: string
          format: uri
          description: |
            **Required when the method has `requires_customer_label`** (e.g. Ship By Platform), rejected otherwise
            (`422`). One shipping label for the **whole order** — it applies to every line. Lenful reads the tracking
            number from it; an unreadable label does not reject the order but is flagged for follow-up.
            With no `shipping_method_code`, a `label_url` selects the Ship By Platform method on its own; if your
            account has no such method, or more than one, the order is rejected (`422` on `shipping_method_code`)
            so you pick the method yourself — shipping is charged per method, so Lenful never picks one for you.
        shipping_address: { $ref: "#/components/schemas/ShippingAddress" }
        line_items:
          type: array
          minItems: 1
          items: { $ref: "#/components/schemas/LineItem" }

    UpdateOrderRequest:
      type: object
      minProperties: 1
      description: |
        Fields to change — at least one. Omitted fields stay unchanged. See `PATCH /orders/{orderId}` for how
        `line_items` replaces the current lines.
      properties:
        shipping_method_code:
          type: integer
          minimum: 0
          description: "Change the shipping method (codes under Orders → Import in the portal). Unknown or inactive code → `422`."
        label_url:
          type: string
          format: uri
          description: "Order-wide label for a method with `requires_customer_label`. Required when switching to such a method or adding lines to it; rejected for other methods (`422`). Omit to keep the current label."
        shipping_address:
          allOf: [{ $ref: "#/components/schemas/ShippingAddress" }]
          description: "Replaces the whole address (send every field you want to keep). Omit to keep the current address."
        line_items:
          type: array
          minItems: 1
          description: "Full list of lines after the edit — see the operation description."
          items:
            allOf:
              - { $ref: "#/components/schemas/LineItem" }
              - type: object
                properties:
                  id:
                    type: string
                    format: uuid
                    description: "`OrderItem.id` of a current line to edit. Omit for a new line. Unknown, repeated or cancelled id → `422`."

    OrderItem:
      type: object
      description: |
        An order line, filtered for sellers. **Never** includes unit cost, supplier id or accounting
        period — those exist on the record but are stripped from every API response.
      properties:
        id: { type: string, format: uuid }
        sku: { type: string }
        product_name: { type: string }
        quantity: { type: integer }
        workflow_status: { $ref: "#/components/schemas/WorkflowStatus" }
        item_revenue: { $ref: "#/components/schemas/Money" }
        alloc_surcharge:
          allOf: [{ $ref: "#/components/schemas/Money" }]
          description: "Surcharges allocated to this line — includes the extra print-area surcharge for every position after the first."
        design_sku:
          type: [string, "null"]
          description: "SKU of the design attached to this line (`null` for dropship or POD without a design). Reuse it as `design_sku` in later orders."

    Order:
      type: object
      properties:
        id: { type: string, format: uuid }
        code: { type: string, example: "LF-2026-000123" }
        store_id: { type: string, format: uuid }
        shipping_method_code: { type: [integer, "null"], description: "Code of the shipping method used." }
        external_order_id: { type: [string, "null"] }
        workflow_status: { $ref: "#/components/schemas/WorkflowStatus" }
        payment_status: { type: string, enum: [unpaid, paid, partially_refunded, refunded] }
        revenue_total: { $ref: "#/components/schemas/Money" }
        currency: { type: string, example: "USD" }
        created_at: { type: string, format: date-time }
        items:
          type: array
          items: { $ref: "#/components/schemas/OrderItem" }

    Tracking:
      type: object
      properties:
        order_item_id: { type: string, format: uuid }
        carrier: { type: [string, "null"] }
        tracking_number: { type: string }
        status: { type: string }
        is_current: { type: boolean }
        updated_at: { type: string, format: date-time }

    Variant:
      type: object
      properties:
        id: { type: string, format: uuid }
        sku: { type: string }
        list_price: { $ref: "#/components/schemas/Money" }
        attributes: { type: object, additionalProperties: true, example: { size: "L", color: "black" } }

    Product:
      type: object
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        type: { type: string, enum: [dropship, pod] }
        print_positions:
          type: array
          description: "POD only (empty for dropship) — the valid `position` codes for `line_items[].designs`."
          items:
            type: object
            properties:
              code: { type: string, example: "front" }
              label: { type: string, example: "Front" }
              is_primary: { type: boolean, description: "Marks the position the catalog treats as the product's main one. Informational only: a POD line may print any subset of the listed positions, so omitting this one is accepted. The number of printed sides is part of the variant's price option (One side / Both sides / N area), not of this list." }

    Design:
      type: object
      required: [id, sku, name, product_id, product_name, product_type, print_file_url, mockup_urls, print_areas, usage_count, created_at]
      properties:
        id: { type: string, format: uuid }
        sku: { type: string, example: "LF-TEE-AB12CD", description: "Readable design code (unique system-wide)." }
        name: { type: string }
        product_id: { type: string, format: uuid }
        product_name: { type: string }
        product_type: { type: string, enum: [dropship, pod] }
        print_file_url: { type: string, description: "Primary production artwork URL (print or embroidery). May be empty for a legacy/import skeleton that has no file yet." }
        mockup_urls:
          type: array
          maxItems: 10
          description: "Mockup gallery (0…10), first is primary. Empty when the design has no mockup yet."
          items: { type: string, format: uri }
        print_areas:
          type: array
          description: "Print positions with regular print artwork URL + size (mm). `design_url` may be empty for an embroidery-only area; use `design_sku` when ordering embroidery/`full_ds` designs."
          items:
            type: object
            required: [position, design_url, width_mm, height_mm]
            properties:
              position: { type: string, example: "front" }
              design_url: { type: string, example: "https://files.lenful.com/…/front.png" }
              width_mm: { type: number }
              height_mm: { type: number }
        usage_count: { type: integer, description: "How many order items currently reference this design." }
        created_at: { type: string, format: date-time }

    DesignListResponse:
      allOf:
        - $ref: "#/components/schemas/Envelope"
        - type: object
          properties:
            data:
              type: object
              properties:
                items: { type: array, items: { $ref: "#/components/schemas/Design" } }
                page: { type: integer }
                limit: { type: integer }
                total: { type: integer }

    WalletLedgerEntry:
      type: object
      properties:
        id: { type: string, format: uuid }
        entry_type: { type: string, enum: [deposit, order_charge, refund, adjustment], example: "order_charge" }
        amount: { $ref: "#/components/schemas/Money" }
        balance_after: { $ref: "#/components/schemas/Money" }
        order_id: { type: [string, "null"], format: uuid, description: "The order this entry belongs to; `null` for entries not tied to an order (e.g. a deposit)." }
        order_code: { type: [string, "null"], example: "LF-240001" }
        created_at: { type: string, format: date-time }

    CancelOrderRequest:
      type: object
      required: [reason]
      additionalProperties: false
      properties:
        reason: { type: string, minLength: 1, maxLength: 500, description: "Why the order is cancelled. Missing or empty → `422`." }

    Pagination:
      type: object
      properties:
        page: { type: integer }
        limit: { type: integer }
        total: { type: integer }
        totalPages: { type: integer }

    Envelope:
      type: object
      description: Standard response envelope. `data` shape varies per endpoint.
      required: [success, message]
      properties:
        success: { type: boolean }
        message: { type: string }

    Error:
      allOf:
        - $ref: "#/components/schemas/Envelope"
        - type: object
          properties:
            success: { type: boolean, example: false }

    OrderResponse:
      allOf:
        - $ref: "#/components/schemas/Envelope"
        - type: object
          properties:
            data: { $ref: "#/components/schemas/Order" }

    OrderListResponse:
      allOf:
        - $ref: "#/components/schemas/Envelope"
        - type: object
          properties:
            data:
              type: object
              properties:
                items: { type: array, items: { $ref: "#/components/schemas/Order" } }
                page: { type: integer }
                limit: { type: integer }
                total: { type: integer }
                totalPages: { type: integer }

    TrackingListResponse:
      allOf:
        - $ref: "#/components/schemas/Envelope"
        - type: object
          properties:
            data:
              type: object
              properties:
                items: { type: array, items: { $ref: "#/components/schemas/Tracking" } }

    Store:
      type: object
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        platform: { type: string, example: "shopify", description: "`manual` for stores you created by hand; otherwise the connected marketplace." }
        is_default: { type: boolean, description: "Used when an order omits `store_id`." }
        status: { type: string, example: "connected" }

    StoreListResponse:
      allOf:
        - $ref: "#/components/schemas/Envelope"
        - type: object
          properties:
            data:
              type: object
              properties:
                items: { type: array, items: { $ref: "#/components/schemas/Store" } }

    ProductListResponse:
      allOf:
        - $ref: "#/components/schemas/Envelope"
        - type: object
          properties:
            data:
              type: object
              properties:
                items: { type: array, items: { $ref: "#/components/schemas/Product" } }
                total: { type: integer }
                page: { type: integer, minimum: 1 }
                limit: { type: integer, minimum: 1, maximum: 100 }

    VariantListResponse:
      allOf:
        - $ref: "#/components/schemas/Envelope"
        - type: object
          properties:
            data:
              type: object
              properties:
                items: { type: array, items: { $ref: "#/components/schemas/Variant" } }

    WalletResponse:
      allOf:
        - $ref: "#/components/schemas/Envelope"
        - type: object
          properties:
            data:
              type: object
              properties:
                balance: { $ref: "#/components/schemas/Money" }
                currency: { type: string, example: "USD" }

    WalletLedgerResponse:
      allOf:
        - $ref: "#/components/schemas/Envelope"
        - type: object
          properties:
            data:
              type: object
              properties:
                items: { type: array, items: { $ref: "#/components/schemas/WalletLedgerEntry" } }
                page: { type: integer }
                limit: { type: integer }
                total: { type: integer }
                totalPages: { type: integer }
