openapi: 3.0.3
info:
  title: MARZI connector contract
  version: "1"
  description: |
    What a customer's website must answer so that the MARZI apps can show its products and orders.
    MARZI calls the website; the website never calls MARZI. Every request carries X-Marzi-Timestamp and X-Marzi-Signature
    (see the guide "Kết nối website", section "Chữ ký"). Only the parts listed by GET /v1/capabilities are used, so a website
    can implement the contract in stages (start with products.read).

    Marketplace orders (the Marzi Market app) use the same paths: products.read, categories.read and, for a shop that signed the
    agreement, POST/GET /v1/members/{memberId}/orders with one dedicated member account. No path or field is specific to it.

    Paths are relative to the connector address typed in the MARZI console, e.g. https://www.example.com/marzi-connector.
    MARZI signs the path WITHOUT that folder ("/v1/products"), so a connector that lives in a folder must remove it first.
servers:
  - url: https://www.example.com/marzi-connector
security:
  - marziSignature: []
components:
  securitySchemes:
    marziSignature:
      type: apiKey
      in: header
      name: X-Marzi-Signature
      description: |
        hex( HMAC-SHA256( secret, timestamp + "\n" + METHOD + "\n" + path?query + "\n" + sha256hex(body) ) ),
        sent with X-Marzi-Timestamp (unix seconds). Refuse a timestamp more than 300 seconds from your clock. Compare in constant time.
  parameters:
    page: { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
    limit: { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 20 }, description: Clamp it; never return a whole table. }
    staffId: { name: staffId, in: path, required: true, schema: { type: string } }
    memberId: { name: memberId, in: path, required: true, schema: { type: string } }
    id: { name: id, in: path, required: true, schema: { type: string } }
    key: { name: key, in: path, required: true, schema: { type: string, pattern: "^[a-z-]+$" } }
  responses:
    Unauthorized: { description: "Bad, missing or stale signature (error: unauthorized), or wrong user name / password (error: invalid_credentials).", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
    Forbidden: { description: "The account may not do this (error: forbidden). The message is shown to the shop staff.", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
    NotFound: { description: "No such thing, or it belongs to someone else (error: not_found). Use 404, never 500.", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
    Invalid: { description: "The request is refused for a reason the person can act on (error: invalid_*). The message is shown to the shopper or staff in their language if you can.", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
  schemas:
    Error:
      type: object
      required: [error, message]
      properties: { error: { type: string, example: not_found }, message: { type: string, example: Product not found } }
    Names:
      type: object
      description: The text in each language. Use "kr" (not "ko") for Korean. Repeat the Vietnamese text if you have no translation.
      required: [vi, en, kr]
      properties: { vi: { type: string }, en: { type: string }, kr: { type: string } }
    NamesOrNull:
      type: object
      nullable: true
      description: Same as Names, or null when the website has nothing to say.
      required: [vi, en, kr]
      properties: { vi: { type: string }, en: { type: string }, kr: { type: string } }
    Capabilities:
      type: object
      required: [capabilities]
      properties:
        connector: { type: string, example: my-website }
        contract: { type: integer, example: 1 }
        capabilities:
          type: array
          items: { type: string, enum: [products.read, categories.read, auth.verify, auth.qr, orders.read, orders.create, staff.verify, staff.orders, staff.orders.write, staff.customers, staff.products, staff.products.write, pages.read] }
    Variant:
      type: object
      required: [id, name, price, stock]
      properties: { id: { type: string }, name: { type: string, example: "Hộp 6 cái" }, price: { type: number, description: Price now for this viewer }, stock: { type: number, nullable: true } }
    ProductOption:
      type: object
      required: [name, value]
      description: Which choice a product is within its group. Plain text in the shop's language; names / values may add the other languages (vi, en, kr).
      properties:
        name: { type: string, example: Màu }
        value: { type: string, example: Đỏ }
        names: { type: object, properties: { vi: { type: string }, en: { type: string }, kr: { type: string } } }
        values: { type: object, properties: { vi: { type: string }, en: { type: string }, kr: { type: string } } }
    ProductGroup:
      type: object
      required: [id, options]
      description: >
        Optional. Products that are choices of one thing (the same shirt in other colours and sizes) are products of their own,
        each with its own price, picture and stock, and share a group id. The app turns them into one card in the list and
        into option buttons (Màu, Size...) on the product page; choosing buttons opens the matching product. A combination
        that does not exist is simply not a product, and the app greys it out.
      properties:
        id: { type: string }
        options: { type: array, items: { $ref: "#/components/schemas/ProductOption" }, description: "The same option names, in the same order, for every product of the group." }
        count: { type: integer, description: "Only when the list was asked to collapse: how many choices the group has." }
        priceFrom: { type: number, description: "Only when the list was asked to collapse: the lowest price among them." }
    Product:
      type: object
      required: [id, code, names, description, price, regularPrice, variants, image, stock]
      properties:
        group: { $ref: "#/components/schemas/ProductGroup" }
        url: { type: string, description: "Optional. Where this product's page is on the website: a full https address on the same website, or a path below the website's address (for example /san-pham/ban001). Used by shared links: a shop can choose to send people who do not have the app straight to this page. Addresses on another website, or not https, are ignored." }
        id: { type: string }
        code: { type: string, nullable: true }
        names: { $ref: "#/components/schemas/Names" }
        description: { type: string }
        price: { type: number, description: "Selling price for this viewer (lowest variant price when sold by variant). 0 = ask the shop." }
        regularPrice: { type: number }
        variants: { type: array, items: { $ref: "#/components/schemas/Variant" } }
        image: { type: string, nullable: true, description: File name only; MARZI adds the image folder you gave in the console. }
        stock: { type: number }
    ProductPage:
      type: object
      required: [items, total]
      properties: { items: { type: array, items: { $ref: "#/components/schemas/Product" } }, total: { type: integer } }
    Category:
      type: object
      required: [id, names]
      properties: { id: { type: string }, names: { $ref: "#/components/schemas/Names" } }
    Member:
      type: object
      required: [memberId, username, name, email, phone]
      properties: { memberId: { type: string }, username: { type: string }, name: { type: string }, email: { type: string, nullable: true }, phone: { type: string, nullable: true } }
    OrderLine:
      type: object
      required: [productId, name, unitPrice, quantity]
      properties: { productId: { type: string }, name: { type: string }, unitPrice: { type: number }, quantity: { type: number } }
    Order:
      type: object
      required: [id, code, status, paid, createdAt, fullName, phone, address, note, subtotal, shippingFee, total, lines]
      properties:
        id: { type: string }
        code: { type: string }
        status: { type: string, enum: [pending, confirmed, packing, shipping, delivered, cancelled], description: "Simplified status shown to the shopper. Map your own statuses to the nearest one; unknown = pending." }
        paid: { type: boolean }
        createdAt: { type: string, format: date-time, nullable: true, description: "ISO 8601 in UTC, e.g. 2026-10-17T05:30:00Z" }
        fullName: { type: string }
        phone: { type: string }
        address: { type: string }
        note: { type: string, nullable: true }
        subtotal: { type: number }
        shippingFee: { type: number }
        total: { type: number }
        lines: { type: array, items: { $ref: "#/components/schemas/OrderLine" } }
        trackingCode: { type: string, nullable: true, description: "Optional. The courier's waybill code if the shop owner has typed one in the website's own admin. The shopper app shows it with a copy button; leave it out or null when there is none." }
    OrderPage:
      type: object
      required: [items, total]
      properties: { items: { type: array, items: { $ref: "#/components/schemas/Order" } }, total: { type: integer } }
    OrderInput:
      type: object
      required: [items, fullName, phone, address, paymentMethodId]
      description: The app says WHAT and HOW MANY, never the price. Price, stock and the total are yours to work out and check.
      properties:
        items: { type: array, items: { type: object, required: [productId, quantity], properties: { productId: { type: string }, variantId: { type: string }, quantity: { type: integer } } } }
        fullName: { type: string }
        phone: { type: string }
        address: { type: string }
        note: { type: string }
        paymentMethodId: { type: string }
    PaymentMethod:
      type: object
      required: [id, names]
      properties: { id: { type: string }, names: { $ref: "#/components/schemas/Names" } }
    Staff:
      type: object
      required: [staffId, username, name, canEdit]
      properties: { staffId: { type: string }, username: { type: string }, name: { type: string }, canEdit: { type: boolean, description: "false = may look but not change" } }
    StatusOption:
      type: object
      required: [id, names]
      properties: { id: { type: integer }, names: { $ref: "#/components/schemas/Names" } }
    StaffOrder:
      allOf:
        - $ref: "#/components/schemas/Order"
        - type: object
          required: [statusId, statusNames, paymentMethodNames, memberId, email, introCode]
          properties:
            statusId: { type: integer, description: The shop's own status number (one of GET .../order-statuses). }
            statusNames: { $ref: "#/components/schemas/NamesOrNull" }
            paymentMethodNames: { $ref: "#/components/schemas/NamesOrNull" }
            memberId: { type: string, nullable: true }
            email: { type: string, nullable: true }
            introCode: { type: string, nullable: true }
    StaffOrderPage:
      type: object
      required: [items, total, amount]
      properties:
        items: { type: array, items: { $ref: "#/components/schemas/StaffOrder" } }
        total: { type: integer, description: Orders matching the filter (all pages). }
        amount: { type: number, description: "Sales of EVERYTHING the filter matches (cancelled orders left out), not just this page." }
    OrderStats:
      type: object
      required: [newSince, byStatus, now]
      properties:
        newSince: { type: integer, description: "Orders (not cancelled) placed after ?since." }
        byStatus: { type: object, additionalProperties: { type: integer }, description: 'Count per status id, e.g. {"1": 3, "4": 20}' }
        now: { type: integer, description: Your clock (unix seconds); the app asks "anything newer than this?" next time. }
    CustomerSummary:
      type: object
      required: [id, name, phoneMasked, orders, spent, lastOrderAt, joinedAt]
      properties: { id: { type: string }, name: { type: string }, phoneMasked: { type: string, nullable: true, example: "090****001" }, orders: { type: integer }, spent: { type: number }, lastOrderAt: { type: string, nullable: true }, joinedAt: { type: string, nullable: true } }
    Customer:
      type: object
      required: [id, name, username, phone, email, address, company, type, referralCode, referredBy, joinedAt, orders, spent, lastOrderAt, recentOrders]
      properties:
        id: { type: string }
        name: { type: string }
        username: { type: string }
        phone: { type: string, nullable: true }
        email: { type: string, nullable: true }
        address: { type: string, nullable: true }
        company: { type: string, nullable: true }
        type: { type: string, nullable: true }
        referralCode: { type: string, nullable: true }
        referredBy: { type: string, nullable: true }
        joinedAt: { type: string, nullable: true }
        orders: { type: integer }
        spent: { type: number }
        lastOrderAt: { type: string, nullable: true }
        recentOrders: { type: array, items: { $ref: "#/components/schemas/StaffOrder" } }
    StaffProduct:
      type: object
      required: [id, code, names, image, visible, regularPrice, salePrice, variants, stock]
      properties:
        id: { type: string }
        code: { type: string, nullable: true }
        names: { $ref: "#/components/schemas/Names" }
        image: { type: string, nullable: true }
        visible: { type: boolean }
        regularPrice: { type: number }
        salePrice: { type: number, description: 0 = no discount }
        variants: { type: array, items: { type: object, required: [id, name, price, salePrice], properties: { id: { type: string }, name: { type: string }, price: { type: number }, salePrice: { type: number } } } }
        stock: { type: number }
    ProductEdit:
      type: object
      description: Only the fields that are present change. Check everything first, then change everything - a bad field must change nothing.
      properties:
        names: { type: object, properties: { vi: { type: string }, en: { type: string }, kr: { type: string } } }
        regularPrice: { type: number, minimum: 0, maximum: 1000000000 }
        salePrice: { type: number, minimum: 0, maximum: 1000000000 }
        variants: { type: array, items: { type: object, required: [id], properties: { id: { type: string }, price: { type: number }, salePrice: { type: number } } } }
    QrSession:
      type: object
      required: [sid, expiresAt, site, requester, roles]
      description: A sign-in the website is showing as a QR code, described so the person can check that it is really theirs.
      properties:
        sid: { type: string, description: "The code in the QR. Random, 16 to 128 characters of A-Z a-z 0-9 - _ (at least 128 bits of randomness)." }
        expiresAt: { type: string, format: date-time, description: "ISO 8601 UTC. Keep codes short-lived (about 2 minutes)." }
        site: { type: string, nullable: true, description: The website's name for the confirmation screen. }
        requester:
          type: object
          required: [ip, device, place, startedAt]
          description: Who opened the sign-in page, as the website knows it. The app shows it so the person can tell a stranger's page from their own.
          properties:
            ip: { type: string, nullable: true }
            device: { type: string, nullable: true, example: Chrome trên Windows }
            place: { type: string, nullable: true, example: Seoul }
            startedAt: { type: string, nullable: true }
        roles:
          type: array
          items: { type: string, enum: [member, staff] }
          description: Which accounts this page accepts. member = a shopper, staff = someone who runs the shop (the website's admin).
    PageSection:
      type: object
      required: [key, kind, names, count]
      properties: { key: { type: string, example: news }, kind: { type: string, enum: [single, list] }, names: { $ref: "#/components/schemas/Names" }, count: { type: integer, description: 0 = nothing to show yet } }
    Article:
      type: object
      required: [id, names, summaries, image, publishedAt]
      properties:
        id: { type: string }
        names: { $ref: "#/components/schemas/Names" }
        summaries: { $ref: "#/components/schemas/Names" }
        image: { type: string, nullable: true, description: "Path on the website (e.g. upload/news/x.jpg); MARZI adds the website address." }
        publishedAt: { type: string, nullable: true }
        contents: { allOf: [{ $ref: "#/components/schemas/Names" }], description: "HTML. Only when ONE article is opened, not in lists." }
paths:
  /v1/capabilities:
    get:
      summary: Which parts of the contract this website implements
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/Capabilities" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /v1/products:
    get:
      summary: Products shown to shoppers (capability products.read)
      parameters:
        - $ref: "#/components/parameters/page"
        - $ref: "#/components/parameters/limit"
        - { name: q, in: query, schema: { type: string }, description: Search in names and code }
        - { name: category, in: query, schema: { type: string }, description: A category id }
        - { name: member, in: query, schema: { type: string }, description: Signed-in shopper; apply member prices }
        - { name: collapse, in: query, schema: { type: string, enum: ["1"] }, description: "Optional, only for websites that use product groups: return one product per group (its first choice) and add group.count and group.priceFrom to it. Websites without groups ignore it." }
        - { name: group, in: query, schema: { type: string }, description: "Optional, product groups only: return every product of this group, not collapsed." }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/ProductPage" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /v1/products/{id}:
    get:
      summary: One product (capability products.read)
      parameters:
        - $ref: "#/components/parameters/id"
        - { name: member, in: query, schema: { type: string } }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/Product" } } } }
        "404": { $ref: "#/components/responses/NotFound" }
  /v1/categories:
    get:
      summary: Product groups (capability categories.read)
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: object, required: [items], properties: { items: { type: array, items: { $ref: "#/components/schemas/Category" } } } } } } }
  /v1/auth/verify:
    post:
      summary: Check a shopper's user name and password (capability auth.verify)
      requestBody: { required: true, content: { application/json: { schema: { type: object, required: [username, password], properties: { username: { type: string }, password: { type: string } } } } } }
      responses:
        "200": { description: The shopper, content: { application/json: { schema: { $ref: "#/components/schemas/Member" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /v1/payment-methods:
    get:
      summary: Ways to pay (capability orders.read or orders.create)
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: object, required: [items], properties: { items: { type: array, items: { $ref: "#/components/schemas/PaymentMethod" } } } } } } }
  /v1/members/{memberId}/orders:
    get:
      summary: A shopper's own orders, newest first (capability orders.read)
      parameters: [{ $ref: "#/components/parameters/memberId" }, { $ref: "#/components/parameters/page" }, { $ref: "#/components/parameters/limit" }]
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/OrderPage" } } } }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      summary: Place an order for a shopper (capability orders.create)
      parameters: [{ $ref: "#/components/parameters/memberId" }]
      requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/OrderInput" } } } }
      responses:
        "200": { description: The order that was made, content: { application/json: { schema: { $ref: "#/components/schemas/Order" } } } }
        "422": { $ref: "#/components/responses/Invalid" }
  /v1/members/{memberId}/orders/{id}:
    get:
      summary: One of the shopper's orders. Someone else's order is 404 (capability orders.read)
      parameters: [{ $ref: "#/components/parameters/memberId" }, { $ref: "#/components/parameters/id" }]
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/Order" } } } }
        "404": { $ref: "#/components/responses/NotFound" }
  /v1/staff/verify:
    post:
      summary: Check a shop staff member's user name and password (capability staff.verify)
      description: Use the website's own admin accounts and rules. 401 for a wrong password, 403 if the account may not manage orders.
      requestBody: { required: true, content: { application/json: { schema: { type: object, required: [username, password], properties: { username: { type: string }, password: { type: string } } } } } }
      responses:
        "200": { description: The staff member, content: { application/json: { schema: { $ref: "#/components/schemas/Staff" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
  /v1/staff/{staffId}/order-statuses:
    get:
      summary: The statuses the shop uses for orders (capability staff.orders)
      parameters: [{ $ref: "#/components/parameters/staffId" }]
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: object, required: [items], properties: { items: { type: array, items: { $ref: "#/components/schemas/StatusOption" } } } } } } }
        "403": { $ref: "#/components/responses/Forbidden" }
  /v1/staff/{staffId}/orders:
    get:
      summary: All orders as the shop sees them (capability staff.orders)
      parameters:
        - $ref: "#/components/parameters/staffId"
        - $ref: "#/components/parameters/page"
        - $ref: "#/components/parameters/limit"
        - { name: status, in: query, schema: { type: integer }, description: A status id }
        - { name: q, in: query, schema: { type: string }, description: "Search in code, name and phone" }
        - { name: since, in: query, schema: { type: integer }, description: Only orders placed after this time (unix seconds) }
        - { name: from, in: query, schema: { type: integer }, description: Placed at or after (unix seconds) }
        - { name: to, in: query, schema: { type: integer }, description: Placed before (unix seconds) }
        - { name: paid, in: query, schema: { type: string, enum: ["0", "1"] } }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/StaffOrderPage" } } } }
        "403": { $ref: "#/components/responses/Forbidden" }
  /v1/staff/{staffId}/orders/stats:
    get:
      summary: Counters for the "new orders" badge (capability staff.orders)
      parameters: [{ $ref: "#/components/parameters/staffId" }, { name: since, in: query, schema: { type: integer } }]
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/OrderStats" } } } }
  /v1/staff/{staffId}/orders/{id}:
    get:
      summary: One order (capability staff.orders)
      parameters: [{ $ref: "#/components/parameters/staffId" }, { $ref: "#/components/parameters/id" }]
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/StaffOrder" } } } }
        "404": { $ref: "#/components/responses/NotFound" }
  /v1/staff/{staffId}/orders/{id}/status:
    post:
      summary: Change an order's status (capability staff.orders.write)
      parameters: [{ $ref: "#/components/parameters/staffId" }, { $ref: "#/components/parameters/id" }]
      requestBody: { required: true, content: { application/json: { schema: { type: object, required: [statusId], properties: { statusId: { type: integer } } } } } }
      responses:
        "200": { description: The order after the change, content: { application/json: { schema: { $ref: "#/components/schemas/StaffOrder" } } } }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Invalid" }
  /v1/staff/{staffId}/orders/{id}/payment:
    post:
      summary: Mark an order paid or not paid (capability staff.orders.write)
      parameters: [{ $ref: "#/components/parameters/staffId" }, { $ref: "#/components/parameters/id" }]
      requestBody: { required: true, content: { application/json: { schema: { type: object, required: [paid], properties: { paid: { type: boolean } } } } } }
      responses:
        "200": { description: The order after the change, content: { application/json: { schema: { $ref: "#/components/schemas/StaffOrder" } } } }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Invalid" }
  /v1/staff/{staffId}/customers:
    get:
      summary: The shop's customers, phone numbers masked (capability staff.customers)
      parameters:
        - $ref: "#/components/parameters/staffId"
        - $ref: "#/components/parameters/page"
        - $ref: "#/components/parameters/limit"
        - { name: q, in: query, schema: { type: string } }
        - { name: sort, in: query, schema: { type: string, enum: [spent, orders, recent, joined] } }
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: object, required: [items, total], properties: { items: { type: array, items: { $ref: "#/components/schemas/CustomerSummary" } }, total: { type: integer } } } } } }
  /v1/staff/{staffId}/customers/{id}:
    get:
      summary: One customer in full (capability staff.customers)
      parameters: [{ $ref: "#/components/parameters/staffId" }, { $ref: "#/components/parameters/id" }]
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/Customer" } } } }
        "404": { $ref: "#/components/responses/NotFound" }
  /v1/staff/{staffId}/products:
    get:
      summary: Products as the shop owner manages them, hidden ones included (capability staff.products)
      parameters:
        - $ref: "#/components/parameters/staffId"
        - $ref: "#/components/parameters/page"
        - $ref: "#/components/parameters/limit"
        - { name: q, in: query, schema: { type: string } }
        - { name: visible, in: query, schema: { type: string, enum: ["0", "1"] } }
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: object, required: [items, total], properties: { items: { type: array, items: { $ref: "#/components/schemas/StaffProduct" } }, total: { type: integer } } } } } }
  /v1/staff/{staffId}/products/{id}:
    get:
      summary: One product for the shop owner (capability staff.products)
      parameters: [{ $ref: "#/components/parameters/staffId" }, { $ref: "#/components/parameters/id" }]
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/StaffProduct" } } } }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      summary: Quick edit of names and prices (capability staff.products.write)
      parameters: [{ $ref: "#/components/parameters/staffId" }, { $ref: "#/components/parameters/id" }]
      requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/ProductEdit" } } } }
      responses:
        "200": { description: The product after the change, content: { application/json: { schema: { $ref: "#/components/schemas/StaffProduct" } } } }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Invalid" }
  /v1/staff/{staffId}/products/{id}/visibility:
    post:
      summary: Show or hide a product in the shop (capability staff.products.write)
      parameters: [{ $ref: "#/components/parameters/staffId" }, { $ref: "#/components/parameters/id" }]
      requestBody: { required: true, content: { application/json: { schema: { type: object, required: [visible], properties: { visible: { type: boolean } } } } } }
      responses:
        "200": { description: The product after the change, content: { application/json: { schema: { $ref: "#/components/schemas/StaffProduct" } } } }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Invalid" }
  /v1/qr-login/{sid}:
    get:
      summary: Describe a QR sign-in the website is showing (capability auth.qr)
      description: Unknown, expired, already used or refused codes are 404. Looking must not sign anyone in.
      parameters: [{ name: sid, in: path, required: true, schema: { type: string } }]
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/QrSession" } } } }
        "404": { $ref: "#/components/responses/NotFound" }
  /v1/qr-login/{sid}/approve:
    post:
      summary: The person in the app agreed. Sign them in on the page that showed the code (capability auth.qr)
      description: |
        MARZI has already checked the person's account on your website (their password was verified through this connector) and
        sends their id, so you decide how to sign the browser in. The code works ONCE. The page must pick the result up with a secret
        that is NOT in the QR code (kept in that browser), otherwise anyone who sees the QR could take the sign-in.
        Refuse (422) an account you do not want to sign in this way, for example an admin when the page is for shoppers.
      parameters: [{ name: sid, in: path, required: true, schema: { type: string } }]
      requestBody: { required: true, content: { application/json: { schema: { type: object, required: [role, userId], properties: { role: { type: string, enum: [member, staff] }, userId: { type: string, description: The memberId or staffId you gave at sign-in. } } } } } }
      responses:
        "200": { description: The page will be signed in, content: { application/json: { schema: { type: object, required: [ok], properties: { ok: { type: boolean } } } } } }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Invalid" }
  /v1/qr-login/{sid}/reject:
    post:
      summary: The person said no. Tell the page and forget the code (capability auth.qr)
      parameters: [{ name: sid, in: path, required: true, schema: { type: string } }]
      requestBody: { required: true, content: { application/json: { schema: { type: object } } } }
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: object, required: [ok], properties: { ok: { type: boolean } } } } } }
        "404": { $ref: "#/components/responses/NotFound" }
  /v1/pages:
    get:
      summary: Sections of the website that can appear as pages in the app (capability pages.read)
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: object, required: [items], properties: { items: { type: array, items: { $ref: "#/components/schemas/PageSection" } } } } } } }
  /v1/pages/{key}:
    get:
      summary: The one article of a "single" page, with contents (capability pages.read)
      parameters: [{ $ref: "#/components/parameters/key" }]
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/Article" } } } }
        "404": { $ref: "#/components/responses/NotFound" }
  /v1/pages/{key}/articles:
    get:
      summary: Articles of a "list" page, without contents (capability pages.read)
      parameters: [{ $ref: "#/components/parameters/key" }, { $ref: "#/components/parameters/page" }, { $ref: "#/components/parameters/limit" }]
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: object, required: [items, total], properties: { items: { type: array, items: { $ref: "#/components/schemas/Article" } }, total: { type: integer } } } } } }
  /v1/pages/{key}/articles/{id}:
    get:
      summary: One article with contents (capability pages.read)
      parameters: [{ $ref: "#/components/parameters/key" }, { $ref: "#/components/parameters/id" }]
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/Article" } } } }
        "404": { $ref: "#/components/responses/NotFound" }
