openapi: 3.0.3
info:
  title: BMWEB connector contract
  version: "1"
  description: |
    What a customer's website must answer so that the BMWEB apps can show its products and orders.
    BMWEB calls the website; the website never calls BMWEB. Every request carries X-Connector-Timestamp and X-Connector-Signature (the older names X-BMWEB-Timestamp and X-BMWEB-Signature are still sent with the same values)
    (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 BMWEB 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 BMWEB console, e.g. https://www.example.com/connector.
    BMWEB 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/connector
security:
  - marziSignature: []
components:
  securitySchemes:
    marziSignature:
      type: apiKey
      in: header
      name: X-Connector-Signature
      description: |
        hex( HMAC-SHA256( secret, timestamp + "\n" + METHOD + "\n" + path?query + "\n" + sha256hex(body) ) ),
        sent with X-Connector-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, courses.read, courses.enroll, staff.courses, staff.courses.write, booking.read, booking.create, booking.payment, booking.packages, booking.games, staff.booking, staff.booking.write] }
    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; BMWEB 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); BMWEB 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." }
    Course:
      type: object
      required: [id, names, description, price, regularPrice, image, lessons]
      properties:
        id: { type: string }
        names: { $ref: "#/components/schemas/Names" }
        description: { type: string }
        price: { type: number, description: What the student pays now }
        regularPrice: { type: number, description: The list price (equal to price when there is no discount) }
        image: { type: string, nullable: true, description: "File name or address of the cover picture." }
        lessons: { type: integer, description: Number of lessons }
        access: { type: string, enum: [none, pending, active], description: "Only when the request named a member: none = not registered, pending = registered and waiting for the shop, active = may learn." }
    CourseDetail:
      allOf:
        - { $ref: "#/components/schemas/Course" }
        - type: object
          required: [sections]
          properties:
            sections:
              type: array
              items:
                type: object
                required: [id, names, lessons]
                properties:
                  id: { type: string }
                  names: { $ref: "#/components/schemas/Names" }
                  lessons:
                    type: array
                    items:
                      type: object
                      required: [id, names, free, durationSeconds, hasVideo]
                      properties: { id: { type: string }, names: { $ref: "#/components/schemas/Names" }, free: { type: boolean }, durationSeconds: { type: integer, nullable: true }, hasVideo: { type: boolean } }
    Lesson:
      type: object
      required: [id, courseId, names, free, content, video]
      properties:
        id: { type: string }
        courseId: { type: string }
        names: { $ref: "#/components/schemas/Names" }
        free: { type: boolean }
        content: { type: string, description: HTML of the lesson text WITHOUT the video embed }
        video:
          type: object
          nullable: true
          required: [kind, url]
          properties:
            kind: { type: string, enum: [youtube, vimeo, mp4, hls, embed], description: "embed = the page of a video service such as Bunny Stream." }
            url: { type: string }
    MemberEnrollment:
      type: object
      required: [id, courseId, status]
      properties:
        id: { type: string }
        courseId: { type: string }
        status: { type: string, enum: [pending, active] }
        price: { type: number }
        createdAt: { type: string, nullable: true }
        activatedAt: { type: string, nullable: true }
        expiresAt: { type: string, nullable: true }
    StaffEnrollment:
      type: object
      required: [id, courseId, courseName, memberName, status]
      properties:
        id: { type: string }
        courseId: { type: string }
        courseName: { type: string }
        memberId: { type: string, nullable: true }
        memberName: { type: string }
        phone: { type: string, nullable: true }
        price: { type: number }
        status: { type: string, enum: [pending, active] }
        createdAt: { type: string, nullable: true }
    BookingVenue:
      type: object
      required: [id, names, address, phone, image, hours]
      properties:
        id: { type: string }
        names: { $ref: "#/components/schemas/Names" }
        address: { type: string, nullable: true }
        phone: { type: string, nullable: true }
        image: { type: string, nullable: true }
        hours:
          type: array
          nullable: true
          description: "Opening hours by weekday (0 = Sunday); null for a business with none (a hotel, tours)."
          items: { type: object, required: [day, open, close], properties: { day: { type: integer, minimum: 0, maximum: 6 }, open: { type: string, example: "06:00" }, close: { type: string, example: "22:00" } } }
    BookingService:
      type: object
      required: [id, venueId, names, description, kind, price, priceUnit, durations, minGuests, maxGuests, pickResource, repeat, packages]
      properties:
        id: { type: string }
        venueId: { type: string }
        names: { $ref: "#/components/schemas/Names" }
        description: { type: string }
        category: { allOf: [{ $ref: "#/components/schemas/NamesOrNull" }] }
        kind: { type: string, enum: [slot, stay, departure], description: "slot = a court, treatment or haircut at a time; stay = nights in a room; departure = a trip with seats." }
        image: { type: string, nullable: true }
        price: { type: number, description: The lowest price }
        priceUnit: { type: string, enum: [booking, hour, night, person] }
        durations: { type: array, items: { type: integer }, description: "Minutes to choose from (kind slot)." }
        minGuests: { type: integer }
        maxGuests: { type: integer }
        pickResource: { type: boolean, description: The customer may choose the court, therapist or room type }
        repeat: { type: boolean, description: May be booked every week }
        packages: { type: boolean, description: Has cards or memberships to buy }
        depositPercent: { type: number, nullable: true }
    BookingResource:
      type: object
      required: [id, serviceIds, names, tags]
      properties:
        id: { type: string }
        serviceIds: { type: array, items: { type: string } }
        names: { $ref: "#/components/schemas/Names" }
        tags: { type: array, items: { type: string }, example: [covered, glass] }
        capacity: { type: integer, nullable: true }
        image: { type: string, nullable: true }
    BookingOffer:
      type: object
      required: [start, end, resourceId, resourceNames, tags, price, left]
      properties:
        start: { type: string, example: "2026-10-22T18:00", description: "Local time of the venue, no zone." }
        end: { type: string, example: "2026-10-22T19:30" }
        resourceId: { type: string, nullable: true, description: "The court, staff member, room type or (for a departure) the trip." }
        resourceNames: { $ref: "#/components/schemas/NamesOrNull" }
        tags: { type: array, items: { type: string } }
        price: { type: number, description: "For this whole choice (the nights, the people asked about)." }
        left: { type: integer, nullable: true, description: "Rooms or seats still free, or null." }
        image: { type: string, nullable: true }
    Booking:
      type: object
      required: [id, code, status, venueId, venueName, serviceId, serviceNames, start, end, guests, price, paidAmount, policy, canCancel, createdAt]
      properties:
        id: { type: string }
        code: { type: string }
        status: { type: string, enum: [pending_payment, pending, confirmed, completed, cancelled, no_show] }
        venueId: { type: string }
        venueName: { type: string }
        serviceId: { type: string }
        serviceNames: { $ref: "#/components/schemas/Names" }
        resourceId: { type: string, nullable: true }
        resourceNames: { $ref: "#/components/schemas/NamesOrNull" }
        start: { type: string }
        end: { type: string }
        guests: { type: integer }
        price: { type: number }
        paidAmount: { type: number }
        deposit:
          type: object
          nullable: true
          required: [amount, paid, dueBy]
          properties: { amount: { type: number }, paid: { type: boolean }, dueBy: { type: string, nullable: true, description: "ISO 8601 with zone; the booking is held until then." } }
        policy:
          type: object
          required: [freeUntil, lateFee]
          properties: { freeUntil: { type: string, nullable: true, description: "ISO 8601 with zone." }, lateFee: { type: number } }
        canCancel: { type: boolean }
        cancelFee: { type: number, nullable: true }
        seriesId: { type: string, nullable: true }
        note: { type: string, nullable: true }
        createdAt: { type: string }
        customer: { type: object, description: Only for staff, properties: { name: { type: string }, phone: { type: string, nullable: true } } }
    BookingInput:
      type: object
      required: [serviceId, start, end, guests]
      properties:
        serviceId: { type: string }
        resourceId: { type: string, nullable: true }
        start: { type: string }
        end: { type: string }
        guests: { type: integer }
        note: { type: string }
        repeat: { type: object, properties: { every: { type: string, enum: [week] }, count: { type: integer, minimum: 2, maximum: 12 } } }
        memberPackageId: { type: string }
    BookingPackage:
      type: object
      required: [id, names, description, type, price]
      properties:
        id: { type: string }
        names: { $ref: "#/components/schemas/Names" }
        description: { type: string }
        type: { type: string, enum: [sessions, membership, credit] }
        price: { type: number }
        sessions: { type: integer, nullable: true }
        validDays: { type: integer, nullable: true }
        discountPercent: { type: number, nullable: true }
        serviceIds: { type: array, nullable: true, items: { type: string } }
    MemberPackage:
      type: object
      required: [id, packageId, names, type, status]
      properties:
        id: { type: string }
        packageId: { type: string }
        names: { $ref: "#/components/schemas/Names" }
        type: { type: string, enum: [sessions, membership, credit] }
        remaining: { type: integer, nullable: true }
        expiresAt: { type: string, nullable: true }
        status: { type: string, enum: [pending_payment, active, expired] }
    StaffPackage:
      allOf:
        - { $ref: "#/components/schemas/MemberPackage" }
        - type: object
          required: [memberName, price]
          properties: { memberName: { type: string }, phone: { type: string, nullable: true }, price: { type: number }, createdAt: { type: string, nullable: true } }
    Game:
      type: object
      required: [id, kind, venueId, venueName, start, end, capacity, players, pricePerPlayer, status, joined, host]
      properties:
        id: { type: string }
        kind: { type: string, enum: [game, class, event] }
        venueId: { type: string }
        venueName: { type: string }
        resourceNames: { $ref: "#/components/schemas/NamesOrNull" }
        start: { type: string }
        end: { type: string }
        title: { type: string, nullable: true }
        level: { type: object, nullable: true, properties: { from: { type: number }, to: { type: number }, kind: { type: string, enum: [match, social] } } }
        capacity: { type: integer }
        players: { type: array, items: { type: object, properties: { name: { type: string }, level: { type: number, nullable: true }, me: { type: boolean } } } }
        pricePerPlayer: { type: number }
        status: { type: string, enum: [open, full, cancelled] }
        joined: { type: boolean }
        host: { type: boolean }
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: |
        BMWEB 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" }
  /v1/courses:
    get:
      summary: Courses shown to students (capability courses.read)
      parameters:
        - { $ref: "#/components/parameters/page" }
        - { $ref: "#/components/parameters/limit" }
        - { name: q, in: query, schema: { type: string }, description: Search in the course names }
        - { name: member, in: query, schema: { type: string }, description: "The signed-in student's id; adds `access` to each course." }
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: object, required: [items, total], properties: { items: { type: array, items: { $ref: "#/components/schemas/Course" } }, total: { type: integer } } } } } }
  /v1/courses/{id}:
    get:
      summary: One course with its chapters and lessons (capability courses.read)
      parameters: [{ $ref: "#/components/parameters/id" }, { name: member, in: query, schema: { type: string } }]
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/CourseDetail" } } } }
        "404": { $ref: "#/components/responses/NotFound" }
  /v1/courses/{courseId}/lessons/{lessonId}:
    get:
      summary: One lesson (capability courses.read)
      description: Answer 403 (error not_enrolled) when the student has not been given this course and the lesson is not free. The message is shown to the student.
      parameters:
        - { name: courseId, in: path, required: true, schema: { type: string } }
        - { name: lessonId, in: path, required: true, schema: { type: string } }
        - { name: member, in: query, required: true, schema: { type: string } }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/Lesson" } } } }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /v1/members/{memberId}/courses:
    get:
      summary: What a student has registered for (capability courses.read)
      parameters: [{ $ref: "#/components/parameters/memberId" }]
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: object, required: [items], properties: { items: { type: array, items: { $ref: "#/components/schemas/MemberEnrollment" } } } } } } }
  /v1/members/{memberId}/courses/{courseId}/enroll:
    post:
      summary: A student asks to learn a course (capability courses.enroll)
      description: Decide whether it opens at once (paid, free) or waits (pending). Registering again returns the current state.
      parameters: [{ $ref: "#/components/parameters/memberId" }, { name: courseId, 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: [status, price], properties: { status: { type: string, enum: [pending, active] }, price: { type: number } } } } } }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Invalid" }
  /v1/staff/{staffId}/enrollments:
    get:
      summary: Registrations of all students (capability staff.courses)
      parameters:
        - { $ref: "#/components/parameters/staffId" }
        - { name: status, in: query, schema: { type: string, enum: [pending, active, all], default: pending } }
        - { name: q, in: query, schema: { type: string }, description: Name, phone or course }
        - { $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/StaffEnrollment" } }, total: { type: integer } } } } } }
        "403": { $ref: "#/components/responses/Forbidden" }
  /v1/staff/{staffId}/enrollments/{id}/status:
    post:
      summary: Switch a registration on or off (capability staff.courses.write)
      parameters: [{ $ref: "#/components/parameters/staffId" }, { $ref: "#/components/parameters/id" }]
      requestBody: { required: true, content: { application/json: { schema: { type: object, required: [active], properties: { active: { type: boolean } } } } } }
      responses:
        "200": { description: The registration after the change, content: { application/json: { schema: { $ref: "#/components/schemas/StaffEnrollment" } } } }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Invalid" }
  /v1/staff/{staffId}/courses/{courseId}/grant:
    post:
      summary: Give a student a course without them registering (capability staff.courses.write)
      parameters: [{ $ref: "#/components/parameters/staffId" }, { name: courseId, in: path, required: true, schema: { type: string } }]
      requestBody: { required: true, content: { application/json: { schema: { type: object, required: [memberId], properties: { memberId: { type: string } } } } } }
      responses:
        "200": { description: The registration, opened, content: { application/json: { schema: { $ref: "#/components/schemas/StaffEnrollment" } } } }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /v1/venues:
    get:
      summary: Places that take bookings (capability booking.read)
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: object, required: [items], properties: { items: { type: array, items: { $ref: "#/components/schemas/BookingVenue" } } } } } } }
  /v1/booking/services:
    get:
      summary: What can be booked (capability booking.read)
      parameters: [{ name: venue, in: query, schema: { type: string } }]
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: object, required: [items], properties: { items: { type: array, items: { $ref: "#/components/schemas/BookingService" } } } } } } }
  /v1/booking/resources:
    get:
      summary: Courts, staff members, room types (capability booking.read)
      parameters: [{ name: service, in: query, schema: { type: string } }]
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: object, required: [items], properties: { items: { type: array, items: { $ref: "#/components/schemas/BookingResource" } } } } } } }
  /v1/booking/availability:
    get:
      summary: The free choices of one service (capability booking.read)
      description: Only what is really free now. A slot or departure is asked by `date`, a stay by `from` and `to`. Prices already include peak hours, weekends, nights and the number of guests.
      parameters:
        - { name: service, in: query, required: true, schema: { type: string } }
        - { name: date, in: query, schema: { type: string, example: "2026-10-22" } }
        - { name: from, in: query, schema: { type: string } }
        - { name: to, in: query, schema: { type: string } }
        - { name: duration, in: query, schema: { type: integer }, description: Minutes (kind slot) }
        - { name: guests, in: query, schema: { type: integer } }
        - { name: resource, in: query, schema: { type: string } }
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: object, required: [kind, offers], properties: { kind: { type: string }, offers: { type: array, items: { $ref: "#/components/schemas/BookingOffer" } } } } } } }
        "404": { $ref: "#/components/responses/NotFound" }
  /v1/members/{memberId}/bookings:
    get:
      summary: A customer's bookings (capability booking.create)
      parameters:
        - { $ref: "#/components/parameters/memberId" }
        - { name: scope, in: query, schema: { type: string, enum: [upcoming, past, all], default: upcoming } }
        - { $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/Booking" } }, total: { type: integer } } } } } }
    post:
      summary: Book (capability booking.create)
      description: With `repeat` the whole series is booked or nothing. Two people can never hold the same court at the same time; the second one gets 409 `slot_taken`.
      parameters: [{ $ref: "#/components/parameters/memberId" }]
      requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/BookingInput" } } } }
      responses:
        "200": { description: The booking (or the series), content: { application/json: { schema: { type: object, required: [items], properties: { items: { type: array, items: { $ref: "#/components/schemas/Booking" } } } } } } }
        "409": { description: "The time was taken (error slot_taken); the message is shown to the customer.", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "422": { $ref: "#/components/responses/Invalid" }
  /v1/members/{memberId}/bookings/{id}:
    get:
      summary: One booking of the customer (capability booking.create)
      parameters: [{ $ref: "#/components/parameters/memberId" }, { $ref: "#/components/parameters/id" }]
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/Booking" } } } }
        "404": { $ref: "#/components/responses/NotFound" }
  /v1/members/{memberId}/bookings/{id}/cancel:
    post:
      summary: Cancel a booking, or the whole series from this one (capability booking.create)
      parameters: [{ $ref: "#/components/parameters/memberId" }, { $ref: "#/components/parameters/id" }]
      requestBody: { required: true, content: { application/json: { schema: { type: object, properties: { all: { type: boolean } } } } } }
      responses:
        "200": { description: The booking after the change, content: { application/json: { schema: { $ref: "#/components/schemas/Booking" } } } }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Invalid" }
  /v1/bookings/{id}/payment:
    post:
      summary: The deposit arrived (capability booking.payment)
      description: BMWEB calls this after the customer paid by bank-transfer QR code. Safe to call twice.
      parameters: [{ $ref: "#/components/parameters/id" }]
      requestBody: { required: true, content: { application/json: { schema: { type: object, required: [amount, reference], properties: { amount: { type: number }, reference: { type: string } } } } } }
      responses:
        "200": { description: The booking after the change, content: { application/json: { schema: { $ref: "#/components/schemas/Booking" } } } }
        "404": { $ref: "#/components/responses/NotFound" }
  /v1/booking/packages:
    get:
      summary: Cards and memberships on sale (capability booking.packages)
      parameters: [{ name: venue, in: query, schema: { type: string } }]
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: object, required: [items], properties: { items: { type: array, items: { $ref: "#/components/schemas/BookingPackage" } } } } } } }
  /v1/members/{memberId}/packages:
    get:
      summary: The cards a customer has (capability booking.packages)
      parameters: [{ $ref: "#/components/parameters/memberId" }]
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: object, required: [items], properties: { items: { type: array, items: { $ref: "#/components/schemas/MemberPackage" } } } } } } }
  /v1/members/{memberId}/packages/{id}/buy:
    post:
      summary: Buy a card (capability booking.packages)
      description: The card is `pending_payment` until the money arrives (POST /v1/member-packages/{id}/payment).
      parameters: [{ $ref: "#/components/parameters/memberId" }, { $ref: "#/components/parameters/id" }]
      requestBody: { required: true, content: { application/json: { schema: { type: object } } } }
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: object, required: [id, status, price], properties: { id: { type: string }, status: { type: string, enum: [pending_payment, active] }, price: { type: number } } } } } }
        "404": { $ref: "#/components/responses/NotFound" }
  /v1/member-packages/{id}/payment:
    post:
      summary: A card was paid for (capability booking.payment)
      parameters: [{ $ref: "#/components/parameters/id" }]
      requestBody: { required: true, content: { application/json: { schema: { type: object, required: [amount, reference], properties: { amount: { type: number }, reference: { type: string } } } } } }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/MemberPackage" } } } }
        "404": { $ref: "#/components/responses/NotFound" }
  /v1/games:
    get:
      summary: Open games, classes and events (capability booking.games)
      parameters:
        - { name: venue, in: query, schema: { type: string } }
        - { name: date, in: query, schema: { type: string } }
        - { name: kind, in: query, schema: { type: string, enum: [game, class, event] } }
        - { name: member, in: query, schema: { type: string }, description: "Fills `joined`, `host` and `players[].me`." }
        - { name: scope, in: query, schema: { type: string, enum: [mine] } }
        - { $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/Game" } }, total: { type: integer } } } } } }
  /v1/members/{memberId}/games:
    post:
      summary: Open a booked court to other players (capability booking.games)
      parameters: [{ $ref: "#/components/parameters/memberId" }]
      requestBody: { required: true, content: { application/json: { schema: { type: object, required: [bookingId, capacity], properties: { bookingId: { type: string }, capacity: { type: integer }, title: { type: string }, level: { type: object, properties: { from: { type: number }, to: { type: number }, kind: { type: string, enum: [match, social] } } } } } } } }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/Game" } } } }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Invalid" }
  /v1/members/{memberId}/games/{id}/join:
    post:
      summary: Take a place in a game (capability booking.games)
      description: 409 `game_full` when it is full; 422 when the customer's level does not fit a "match" game.
      parameters: [{ $ref: "#/components/parameters/memberId" }, { $ref: "#/components/parameters/id" }]
      requestBody: { required: true, content: { application/json: { schema: { type: object } } } }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/Game" } } } }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { description: The game is full, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "422": { $ref: "#/components/responses/Invalid" }
  /v1/members/{memberId}/games/{id}/leave:
    post:
      summary: Give up the place (capability booking.games)
      parameters: [{ $ref: "#/components/parameters/memberId" }, { $ref: "#/components/parameters/id" }]
      requestBody: { required: true, content: { application/json: { schema: { type: object } } } }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/Game" } } } }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Invalid" }
  /v1/staff/{staffId}/bookings:
    get:
      summary: The calendar for the shop's staff (capability staff.booking)
      parameters:
        - { $ref: "#/components/parameters/staffId" }
        - { name: from, in: query, schema: { type: string } }
        - { name: to, in: query, schema: { type: string } }
        - { name: venue, in: query, schema: { type: string } }
        - { name: status, in: query, schema: { type: string } }
        - { name: q, in: query, schema: { type: string }, description: "Code, name or phone" }
        - { $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/Booking" } }, total: { type: integer } } } } } }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      summary: Book for a customer who came to the counter or phoned (capability staff.booking.write)
      parameters: [{ $ref: "#/components/parameters/staffId" }]
      requestBody: { required: true, content: { application/json: { schema: { type: object, required: [serviceId, start, end, guests, customer], properties: { serviceId: { type: string }, resourceId: { type: string }, start: { type: string }, end: { type: string }, guests: { type: integer }, note: { type: string }, customer: { type: object, required: [name], properties: { name: { type: string }, phone: { type: string } } } } } } } }
      responses:
        "200": { description: The booking, content: { application/json: { schema: { $ref: "#/components/schemas/Booking" } } } }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { description: The time was taken, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "422": { $ref: "#/components/responses/Invalid" }
  /v1/staff/{staffId}/bookings/{id}:
    get:
      summary: One booking for staff (capability staff.booking)
      parameters: [{ $ref: "#/components/parameters/staffId" }, { $ref: "#/components/parameters/id" }]
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/Booking" } } } }
        "404": { $ref: "#/components/responses/NotFound" }
  /v1/staff/{staffId}/bookings/{id}/status:
    post:
      summary: Confirm, finish, mark a no-show or cancel (capability staff.booking.write)
      parameters: [{ $ref: "#/components/parameters/staffId" }, { $ref: "#/components/parameters/id" }]
      requestBody: { required: true, content: { application/json: { schema: { type: object, required: [status], properties: { status: { type: string, enum: [confirmed, completed, no_show, cancelled] } } } } } }
      responses:
        "200": { description: The booking after the change, content: { application/json: { schema: { $ref: "#/components/schemas/Booking" } } } }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Invalid" }
  /v1/staff/{staffId}/bookings/{id}/payment:
    post:
      summary: Staff saw the money (capability staff.booking.write)
      description: Without `amount` the deposit (or, with no deposit, the whole price) is taken as paid.
      parameters: [{ $ref: "#/components/parameters/staffId" }, { $ref: "#/components/parameters/id" }]
      requestBody: { required: true, content: { application/json: { schema: { type: object, properties: { paid: { type: boolean }, amount: { type: number } } } } } }
      responses:
        "200": { description: The booking after the change, content: { application/json: { schema: { $ref: "#/components/schemas/Booking" } } } }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /v1/staff/{staffId}/packages:
    get:
      summary: The cards customers bought (capability staff.booking)
      parameters:
        - { $ref: "#/components/parameters/staffId" }
        - { name: status, in: query, schema: { type: string, enum: [pending_payment, active, all], default: pending_payment } }
        - { name: q, in: query, schema: { type: string } }
        - { $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/StaffPackage" } }, total: { type: integer } } } } } }
  /v1/staff/{staffId}/packages/{id}/payment:
    post:
      summary: Staff saw the money for a card; switch it on (capability staff.booking.write)
      parameters: [{ $ref: "#/components/parameters/staffId" }, { $ref: "#/components/parameters/id" }]
      requestBody: { required: true, content: { application/json: { schema: { type: object, properties: { paid: { type: boolean } } } } } }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/StaffPackage" } } } }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
