openapi: 3.0.3
info:
  title: Bruce API (reverse-engineered)
  version: "v52"
  description: |
    Reconstructed from Charles captures of the Bruce Studios iOS app 14.4.2 (build 538).
    Evidence: capture 1 = `captures/1`, capture 2 = `captures/2`, capture 3 = `captures/3`, capture 4 = `captures/4`, capture 5 = `captures/5`, capture 6 = `captures/6`, capture 7 = `captures/7`, capture 8 = `captures/8` (file numbers in `x-evidence`).
    Unofficial; shapes are abbreviated to the fields observed. See API.md.
servers:
  - url: https://api.bruce.app/v52
security:
  - accessToken: []
tags:
  - name: Auth
  - name: Studios
  - name: Classes
  - name: Booking
  - name: Check-in
  - name: Explore
  - name: User
  - name: Media
  - name: Social
  - name: Settings
  - name: Payments

paths:
  /access/authenticate:
    post:
      tags: [Auth]
      summary: Log in with email + password
      security: []
      x-evidence: ["c2:461"]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AuthenticateRequest" }
      responses:
        "200":
          description: |
            Tokens. `access_token_expires_at` is 3 h after login, but the server keeps accepting the access token
            long after it (the c2 token still got 211 x 200 in c8, ~27 h past that time). No refresh call has been
            observed in captures 1-8 and the original app stores only the access token; a 401 means "log in again".
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AuthenticateResponse" }
  /access/logout:
    delete:
      tags: [Auth]
      summary: Log out (invalidates the access token)
      x-evidence: ["c2:442"]
      requestBody:
        content:
          application/json:
            schema: { $ref: "#/components/schemas/LocaleBody" }
      responses:
        "200": { description: Empty body }

  /studio/filter:
    get:
      tags: [Studios]
      summary: Paged studio list (Explore / map)
      description: 30 per page; follow `metadata.pagination.next_cursor`. Capture 2 paged 39 pages = 691 SE studios.
      x-evidence: ["c2:153", "c2:162", "c2:1133"]
      parameters:
        - { name: "filter[status][in]", in: query, schema: { type: string, example: live } }
        - { name: "filter[country_code][eq]", in: query, schema: { type: string, example: SE } }
        - name: fields
          in: query
          schema: { type: string, example: "id,name,photo,status,city_id,vicinity,rating,qr_code_id,has_classes,has_openings,integration_door_type,time_zone,visit_limit_id" }
        - name: include
          in: query
          schema: { type: string, example: "addresses,addresses_address,categories,categories_category,visit_limit,photos,photos_photo" }
        - $ref: "#/components/parameters/limit"
        - $ref: "#/components/parameters/nextCursor"
        - $ref: "#/components/parameters/locale"
      responses:
        "200":
          description: Studios page
          content:
            application/json:
              schema:
                type: object
                properties:
                  studios: { type: array, items: { $ref: "#/components/schemas/Studio" } }
                  metadata: { $ref: "#/components/schemas/Metadata" }
  /studio/{studioId}:
    get:
      tags: [Studios]
      summary: Studio detail
      x-evidence: ["c2:51", "c2:212", "c2:1015"]
      parameters:
        - $ref: "#/components/parameters/studioId"
        - { name: include_addresses, in: query, schema: { type: boolean } }
        - { name: include_categories, in: query, schema: { type: boolean } }
        - { name: include_facilities, in: query, schema: { type: boolean } }
        - { name: include_studio_photos, in: query, schema: { type: boolean } }
        - { name: include_user_booking_limit, in: query, schema: { type: boolean } }
        - { name: include_alerts, in: query, schema: { type: boolean } }
        - $ref: "#/components/parameters/locale"
      responses:
        "200":
          description: Studio
          content:
            application/json:
              schema:
                type: object
                properties:
                  studio: { $ref: "#/components/schemas/Studio" }
  /studio/{studioId}/opening_hour/schedule:
    get:
      tags: [Studios]
      summary: Opening-hour occurrences with attendance info
      x-evidence: ["c2:215", "c2:1018"]
      parameters: [{ $ref: "#/components/parameters/studioId" }]
      responses:
        "200":
          description: Schedule
          content:
            application/json:
              schema:
                type: object
                properties:
                  schedule:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: integer }
                        start: { type: string, format: date-time }
                        end: { type: string, format: date-time }
                        is_all_day: { type: boolean }
                        opening_hour: { type: object }
                        studio_user_attendance_info: { type: object }
  /studio/user_visit_limit:
    # c3:56, c4:34/73/85: with studio_id + month, limits include type STUDIO (pill), MONTHLY, WELLNESS_MONTHLY, SIMULTANEOUS.
    get:
      tags: [Studios]
      summary: Remaining visits per visit-limit
      x-evidence: ["62"]
      parameters: [{ $ref: "#/components/parameters/userId" }]
      responses:
        "200": { description: "`{limits:[{total,used,remaining,within_limit,resets_at,type,items}]}`" }
  /studios:
    get:
      tags: [Studios]
      summary: Legacy public studio list (v1, unauthenticated, ~101 studios)
      security: []
      servers: [{ url: "https://api.bruce.app/v1" }]
      responses:
        "200": { description: "`{studios:[...]}`" }

  /class:
    get:
      tags: [Classes]
      summary: Class schedule (per city/day or per studio), paged
      x-evidence: ["c2:20", "c2:214", "c2:1144"]
      parameters:
        - { name: studio_city_id, in: query, schema: { type: integer, example: 1 }, description: "1 = Stockholm" }
        - { name: studio_id, in: query, schema: { type: integer } }
        - { name: presentation_studio_id, in: query, schema: { type: integer } }
        - { name: start_time_after, in: query, schema: { type: string, example: "2026-10-11T00:00:00" } }
        - { name: start_time_before, in: query, description: "Ignored when studio_id is set (the list then runs past the day).", schema: { type: string, example: "2026-10-12T00:00:00" } }
        - { name: opening, in: query, schema: { type: boolean }, description: "true = opening-hour slots instead of classes" }
        - { name: studio_status, in: query, schema: { type: string, example: live } }
        - { name: deleted, in: query, schema: { type: boolean, example: false }, description: "Not sent by the official app; without it soft-deleted classes (deleted:true) are included" }
        - { name: include_booking_credit_costs, in: query, schema: { type: boolean } }
        - { name: include_class_categories, in: query, schema: { type: boolean } }
        - { name: include_absolute_times, in: query, schema: { type: boolean } }
        - $ref: "#/components/parameters/limit"
        - $ref: "#/components/parameters/nextCursor"
        - $ref: "#/components/parameters/locale"
      responses:
        "200":
          description: Classes page
          content:
            application/json:
              schema:
                type: object
                properties:
                  classes: { type: array, items: { $ref: "#/components/schemas/Class" } }
                  metadata: { $ref: "#/components/schemas/Metadata" }
  /class/{classId}:
    get:
      tags: [Classes]
      summary: Class detail (shown before booking)
      description: |
        With `include_studio_alerts=true` the embedded `studio.alerts[]` carries the notice banner
        (`type` INFO or CRITICAL, `message`, `order`, `start_date`, `end_date`). Expired alerts can still be
        returned despite `exclude_studio_expired_alerts=true`, so filter by `end_date` [c7:157].
      x-evidence: ["c2:50", "c2:58", "c7:157"]
      parameters:
        - { name: classId, in: path, required: true, schema: { type: integer } }
        - { name: include_studio, in: query, schema: { type: boolean } }
        - { name: include_studio_user_attendance_info, in: query, schema: { type: boolean } }
        - { name: include_studio_alerts, in: query, schema: { type: boolean }, description: "Embeds `studio.alerts[]`" }
        - { name: exclude_studio_expired_alerts, in: query, schema: { type: boolean }, description: "Not reliable: c7:30 still returned an alert that ended in 2023; filter on start_date/end_date" }
        - $ref: "#/components/parameters/locale"
      responses:
        "200":
          description: Class
          content:
            application/json:
              schema:
                type: object
                properties:
                  class: { $ref: "#/components/schemas/Class" }
  /studio/class/alternative_recommendation:
    get:
      tags: [Classes]
      summary: Similar classes (nearby, around the same time), offered for a fully booked class
      x-evidence: ["c2:53", "c7:24", "c7:41"]
      parameters:
        - { name: class_id, in: query, required: true, schema: { type: integer } }
        - $ref: "#/components/parameters/locale"
      responses:
        "200":
          description: Class cards
          content:
            application/json:
              schema:
                type: object
                properties:
                  class_cards: { type: array, items: { $ref: "#/components/schemas/ClassCard" } }
                  metadata: { type: object, nullable: true }
  /class/{classId}/invite:
    post:
      tags: [Classes]
      summary: Invite friends to a class
      description: |
        Sends the class to the given friends in their Bruce app. The response returns the invitees' full user
        records (including private fields); clients should ignore it.
      x-evidence: ["c7:168"]
      parameters:
        - { name: classId, in: path, required: true, schema: { type: integer } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [user_ids]
              properties:
                user_ids: { type: array, items: { type: integer }, description: Friend user ids }
                locale: { type: string, example: en }
      responses:
        "200":
          description: "`{invited:[<user>]}`"

  /booking:
    get:
      tags: [Booking]
      summary: Bookings, paged (mine, friends' or a class's attendees)
      description: |
        `user_id=<me>` lists my bookings; `friends=true` my friends' upcoming ones [c3:106].
        My bookings include the ones already checked in (`checked_in_at` set); the original's list request
        (`class_start_time_after=<now-24h>&deleted=false&include_class=true&include_class_studio=true`) keeps showing
        them [c8:404, c8:410]. `reviewable=true&include_class=true` lists checked-in, ended bookings without a
        review: the original calls it at every start and opens its "Rate class" dialog for them [c3:27, c8:415].
        `class_id=<id>&include_user=true&include_message=true` lists everyone booked on a class [c7:160]: members
        with "Bruce Studios Social" on carry `user_id` and `user{id,first_name,photo,photo_valid,social,city_id,display_name}`;
        everyone else comes back without `user_id`/`user` (the original app shows them as "Hidden").
      x-evidence: ["75", "86", "c3:106", "c7:160", "c8:404", "c8:410", "c8:415"]
      parameters:
        - $ref: "#/components/parameters/userId"
        - { name: class_id, in: query, schema: { type: integer } }
        - { name: friends, in: query, schema: { type: boolean } }
        - { name: include_user, in: query, schema: { type: boolean } }
        - { name: include_message, in: query, schema: { type: boolean } }
        - { name: deleted, in: query, schema: { type: boolean, example: false } }
        - { name: include_class, in: query, schema: { type: boolean } }
        - { name: include_class_studio, in: query, schema: { type: boolean } }
        - { name: reviewable, in: query, schema: { type: boolean }, description: "Checked-in, ended, not yet reviewed (c8:415)" }
        - { name: class_start_time_after, in: query, schema: { type: string, format: date-time } }
        - $ref: "#/components/parameters/limit"
        - $ref: "#/components/parameters/nextCursor"
      responses:
        "200":
          description: Bookings
          content:
            application/json:
              schema:
                type: object
                properties:
                  bookings: { type: array, items: { $ref: "#/components/schemas/Booking" } }
                  metadata: { $ref: "#/components/schemas/Metadata" }
    post:
      tags: [Booking]
      summary: Book a class
      x-evidence: ["c2:84"]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateBookingRequest" }
      responses:
        "200":
          description: Created booking
          content:
            application/json:
              schema:
                type: object
                properties:
                  booking: { $ref: "#/components/schemas/Booking" }
  /booking/{bookingId}:
    delete:
      tags: [Booking]
      summary: Cancel a booking (soft delete)
      x-evidence: ["c2:120"]
      parameters:
        - { name: bookingId, in: path, required: true, schema: { type: integer } }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                locale: { type: string, example: en }
                include_user: { type: boolean }
      responses:
        "200":
          description: Booking with `deleted:true`
          content:
            application/json:
              schema:
                type: object
                properties:
                  booking: { $ref: "#/components/schemas/Booking" }
  /booking/aggregate:
    get:
      tags: [Booking]
      summary: Booking counts per day
      description: |
        Drives the Statistics bar chart (visits per month, summed from the days). The visit list under it is
        `GET /booking` with `user_id`, `include_class`, `include_class_studio` and no time window, keeping the
        bookings with `checked_in_at` (the official app's list request is not captured).
      x-evidence: ["70", "c3:396"]
      parameters: [{ $ref: "#/components/parameters/userId" }]
      responses:
        "200": { description: "`{booking_aggregates:[{day,count}]}`" }
  /booking/voucher:
    get:
      tags: [Booking]
      summary: Booking vouchers
      x-evidence: ["33"]
      parameters: [{ name: owner_id, in: query, schema: { type: integer } }]
      responses:
        "200": { description: "`{booking_vouchers:[...]}`" }
  /booking/offline/done:
    post:
      tags: [Booking]
      summary: Sync offline check-ins (sent at app start)
      description: |
        Also the only write in capture 6 (areas session). Custom areas have no endpoint: the original app
        keeps them on the device and filters client-side (c6 shows no area/zone/polygon request).
      x-evidence: ["13", "47", "160", "c6:458", "c6:459", "c6:569"]
      requestBody:
        content:
          application/json:
            schema: { $ref: "#/components/schemas/LocaleBody" }
      responses:
        "200": { description: Empty body }

  /studio/check_in:
    post:
      tags: [Check-in]
      summary: Check in at a studio door (from the door QR / beacon)
      description: |
        Door QR format: `stc://open-door?major=<MAJOR>&minor=<DOOR_ID>`.
        `major` identifies the gym (matches studio `qr_code_id` = `stc:<MAJOR>`), `minor` the door.
        For a drop-in gym the check-in creates the booking itself.
        The only captured check-in [c1:163] carries `meta` from the scanned door QR. `meta` is optional: a remote
        check-in with only `qr_code_id` ("stc:<major>") at an STC/BRP door gym answered HTTP 400 but the booking was
        checked in (`checked_in_at` set, ticket shown in the official app). Clients should re-read
        `GET /booking` after an error status before reporting a failed check-in.
      x-evidence: ["163"]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CheckInRequest" }
      responses:
        "200":
          description: Resulting booking(s)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CheckInResponse" }

  /home_screen:
    get:
      tags: [Explore]
      summary: Home sections (categories, spotlight, nearby gyms, new studios)
      x-evidence: ["46"]
      parameters:
        - { name: coordinates, in: query, schema: { type: string, example: "59.31,18.06" } }
        - $ref: "#/components/parameters/locale"
      responses:
        "200": { description: "`{sections:[{title,type,items}]}`" }
  /category:
    get:
      tags: [Explore]
      summary: Categories
      x-evidence: ["19"]
      parameters: [{ name: "filter[main][eq]", in: query, schema: { type: boolean } }]
      responses:
        "200": { description: "`{categories:[{id,code,icon,title,main,parent_id}]}`" }
  /location/city:
    get:
      tags: [Explore]
      summary: Cities
      x-evidence: ["43"]
      parameters:
        - { name: code, in: query, schema: { type: string, example: STO } }
        - { name: include_country, in: query, schema: { type: boolean } }
      responses:
        "200": { description: "`{cities:[{id,name,time_zone,coordinates,credit_price,booking_methods,country}]}`" }
  /config:
    get:
      tags: [Explore]
      summary: App config (minimum versions)
      x-evidence: ["7"]
      responses:
        "200": { description: Config }

  /user/{userId}:
    get:
      tags: [User]
      summary: User profile
      x-evidence: ["15"]
      parameters: [{ $ref: "#/components/parameters/userIdPath" }]
      responses:
        "200": { description: "`{user:{id,email,first_name,last_name,...}}`" }
    put:
      tags: [User]
      summary: Update profile
      x-evidence: ["55", "c2:337"]
      parameters: [{ $ref: "#/components/parameters/userIdPath" }]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                first_name: { type: string }
                last_name: { type: string }
      responses:
        "200": { description: Updated user }
  /membership/user_info:
    get:
      tags: [User]
      summary: Membership, tier, available credits
      x-evidence: ["14"]
      parameters: [{ $ref: "#/components/parameters/userId" }]
      responses:
        "200": { description: Membership info }

  /image/{transform}/{fileId}.jpg:
    get:
      tags: [Media]
      summary: Image proxy (302 to Cloudinary)
      description: |
        Redirects to `https://res.cloudinary.com/dbo5hzaqa/image/upload/{transform}/{fileId}.jpg` (public, no auth).
        `fileId` is a studio's `photo` / `studio_photos[].photo.file_id`.
      security: []
      x-evidence: ["58", "c2:1006"]
      parameters:
        - { name: transform, in: path, required: true, schema: { type: string, example: "c_fill,dpr_3,h_252,q_60,w_402" } }
        - { name: fileId, in: path, required: true, schema: { type: string, example: z63qkfnoqry2dz7uzrkf } }
      responses:
        "302": { description: "Location: Cloudinary URL" }

  /bookmark:
    post:
      tags: [Booking]
      summary: Bookmark a class
      x-evidence: ["c3:73"]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                class_id: { type: integer }
                locale: { type: string }
      responses:
        "200": { description: "{bookmark:{id,user_id,class_id,created_at}}" }
  /bookmark/{bookmarkId}:
    delete:
      tags: [Booking]
      summary: Remove a bookmark (bookmark id)
      x-evidence: ["c3:77"]
      parameters:
        - { name: bookmarkId, in: path, required: true, schema: { type: integer } }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                locale: { type: string }
      responses:
        "200": { description: "empty" }
  /favorite:
    get:
      tags: [Studios]
      summary: Favourites (all of them without studio_id [c5:39], or the state for one studio)
      x-evidence: ["c3:88", "c3:117", "c5:39"]
      parameters:
        - { name: studio_id, in: query, required: false, schema: { type: integer } }
      responses:
        "200": { description: "{favorites:[...]}" }
    post:
      tags: [Studios]
      summary: Favourite a studio
      x-evidence: ["c3:93"]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                studio_id: { type: integer }
                locale: { type: string }
      responses:
        "200": { description: "{favorite:{id,studio_id,place_id,order}}" }
  /favorite/{favoriteId}:
    delete:
      tags: [Studios]
      summary: Unfavourite (favorite id)
      x-evidence: ["c3:96"]
      parameters:
        - { name: favoriteId, in: path, required: true, schema: { type: integer } }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                locale: { type: string }
      responses:
        "200": { description: "empty" }
  /notification:
    get:
      tags: [User]
      summary: In-app notifications (no paging, no mark-read call)
      x-evidence: ["42", "159", "178"]
      parameters:
        - { name: user_id, in: query, required: true, schema: { type: integer } }
      responses:
        "200": { description: "{notifications:[{id,user_id,created_at,type,title,message,read,hidden,im,push,email,sms,info,channel,meta:{class_id,studio_id,photo,user_photo,inviting_user_name}}]}. type: STANDBY_AVAILABLE (Free spot; does not book), BOOKING_REMINDER, BOOKING_CANCELLED, CLASS_MESSAGE, CHECK_IN_REMINDER, USER_STRIKE, USER_BLOCK, USER_STRIKE_UNBLOCKED, CLASS_INVITE, PAUSE_ENDING. meta is missing on USER_STRIKE_UNBLOCKED and PAUSE_ENDING." }
  /standby:
    get:
      tags: [Booking]
      summary: My waitlist entries
      x-evidence: ["c2:474", "c3:18"]
      parameters:
        - { name: user_id, in: query, required: false, schema: { type: integer } }
        - { name: class_start_time_after, in: query, required: false, schema: { type: string } }
        - { name: include_class, in: query, required: false, schema: { type: boolean } }
      responses:
        "200": { description: "{standbys:[{id,class_id,class}]}" }
    post:
      tags: [Booking]
      summary: Join a class waitlist
      x-evidence: ["c3:65"]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                class_id: { type: integer }
                locale: { type: string }
      responses:
        "200": { description: "{standby:{id,class_id,user_id,created_at}}" }
  /standby/{standbyId}:
    delete:
      tags: [Booking]
      summary: Leave a waitlist
      x-evidence: ["c3:69"]
      parameters:
        - { name: standbyId, in: path, required: true, schema: { type: integer } }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                locale: { type: string }
      responses:
        "200": { description: "{waitlist:{deleted:true},standby:{deleted:true}}" }
  /review:
    get:
      tags: [Studios]
      summary: Studio reviews with comments
      x-evidence: ["c3:101", "c3:121", "c3:592"]
      parameters:
        - { name: studio_id, in: query, required: false, schema: { type: integer } }
        - { name: comment, in: query, required: false, schema: { type: boolean } }
        - { name: hidden, in: query, required: false, schema: { type: boolean } }
        - { name: order_by, in: query, required: false, schema: { type: string } }
        - { name: limit, in: query, required: false, schema: { type: integer } }
        - { name: include_booking, in: query, required: false, schema: { type: boolean } }
        - { name: include_booking_class, in: query, required: false, schema: { type: boolean } }
        - { name: include_booking_user, in: query, required: false, schema: { type: boolean } }
      responses:
        "200": { description: "{reviews:[{id,rating,comment,created_at,booking:{class:{opening,title,start_time},user:{first_name,photo}}}]}" }
    post:
      tags: [Booking]
      summary: Rate a class after attending it
      description: |
        Sent from the "Rate class" dialog the original opens at start for bookings from
        `GET /booking?reviewable=true` [c8:415 -> c8:467]. There is no review notification type and no other
        trigger. `public:false` is "Send (anonymous)". The captured review had no comment (and no `comment`
        key); `comment` is the field name of review objects [c3:101]. The response booking carries `review_id`.
      x-evidence: ["c8:467"]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateReviewRequest" }
      responses:
        "200":
          description: The new review with its booking
          content:
            application/json:
              schema:
                type: object
                properties:
                  review: { $ref: "#/components/schemas/Review" }
  /friend_request/{requestId}/respond:
    post:
      tags: [Social]
      summary: Accept/decline a friend request
      x-evidence: ["c3:245"]
      parameters:
        - { name: requestId, in: path, required: true, schema: { type: integer } }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                accept: { type: boolean }
                include_friend_user: { type: boolean }
                locale: { type: string }
      responses:
        "200": { description: "{}" }
  /user/{userId}/notification_setting:
    get:
      tags: [Settings]
      summary: Notification settings
      x-evidence: ["c3:279", "c3:326"]
      parameters:
        - { name: userId, in: path, required: true, schema: { type: integer } }
      responses:
        "200": { description: "{notification_settings:[{id,marketing_email,marketing_push,marketing_sms,types:[{key,name,description,enabled}]}]}" }
  /user/{userId}/notification_setting/{settingId}:
    put:
      tags: [Settings]
      summary: Update marketing flags or push types (send only changed fields, or the full types array)
      x-evidence: ["c3:319", "c3:327"]
      parameters:
        - { name: userId, in: path, required: true, schema: { type: integer } }
        - { name: settingId, in: path, required: true, schema: { type: integer } }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                marketing_push: { type: boolean }
                marketing_email: { type: boolean }
                marketing_sms: { type: boolean }
                types: { type: array }
                locale: { type: string }
      responses:
        "200": { description: "{notification_setting:{...}}" }
  /user/{userId}/calendar/{secret}.ics:
    get:
      tags: [User]
      summary: Calendar subscription feed (URL contains the user secret; treat as a credential)
      x-evidence: ["c3:341", "c3:370"]
      parameters:
        - { name: userId, in: path, required: true, schema: { type: integer } }
        - { name: secret, in: path, required: true, schema: { type: string } }
      responses:
        "200": { description: "text/calendar" }
  /payment:
    get:
      tags: [Payments]
      summary: Payment history (amounts in öre)
      x-evidence: ["c2:388", "c3:454"]
      parameters:
        - { name: user_id, in: query, required: false, schema: { type: integer } }
        - { name: next_cursor, in: query, required: false, schema: { type: string } }
      responses:
        "200": { description: "{payments:[{id,created_at,amount,original_amount,currency,type,status,admission}]}" }
  /payment/{paymentId}/receipt.pdf:
    get:
      tags: [Payments]
      summary: Receipt PDF
      x-evidence: ["c3:536", "c3:549"]
      parameters:
        - { name: paymentId, in: path, required: true, schema: { type: integer } }
      responses:
        "200": { description: "application/pdf" }
  /payment/collective/invoice.pdf:
    get:
      tags: [Payments]
      summary: Collective invoice PDF
      x-evidence: ["c3:543"]
      parameters:
        - { name: payment_ids, in: query, required: false, schema: { type: string } }
      responses:
        "200": { description: "application/pdf" }
  /faq:
    get:
      tags: [User]
      summary: FAQ entries
      x-evidence: ["c3:407"]
      responses:
        "200": { description: "{faqs:[{id,section_id,question,answer}]}" }
  /faq/section:
    get:
      tags: [User]
      summary: FAQ sections
      x-evidence: ["c3:410"]
      responses:
        "200": { description: "{sections:[{id,title,tags}]}" }
  /search:
    get:
      tags: [Explore]
      summary: Indexed search (studios/classes); hits are {id,index,type:STUDIO,fields:{id,name,city,distance,photo,qr_code_id,status}}
      x-evidence: ["c3:603", "c3:605", "c5:423", "c5:424"]
      parameters:
        - { name: query, in: query, required: false, schema: { type: string } }
        - { name: type, in: query, required: false, schema: { type: string } }
        - { name: latitude, in: query, required: false, schema: { type: number } }
        - { name: longitude, in: query, required: false, schema: { type: number } }
        - { name: city_code, in: query, required: false, schema: { type: string } }
        - { name: search_mode, in: query, required: false, schema: { type: string } }
      responses:
        "200": { description: "{result:[]}" }
  /voucher/redeem:
    post:
      tags: [User]
      summary: Redeem a voucher code
      x-evidence: ["c3:275"]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                code: { type: string }
                user_id: { type: integer }
                locale: { type: string }
      responses:
        "200": { description: "voucher result or {errors:[...]}" }
  /membership/info:
    get:
      tags: [User]
      summary: Plan price for a tier/commitment (öre)
      x-evidence: ["c2:426"]
      parameters:
        - { name: city_code, in: query, required: false, schema: { type: string } }
        - { name: tier_code, in: query, required: false, schema: { type: string, enum: [BASE, BLACK, EPIC] } }
        - { name: commitment_months, in: query, required: false, schema: { type: integer } }
      responses:
        "200": { description: "{membership_info:{original_price,original_flex_price,pre_pay_price,discounts,...}}" }
  /user/{userId}/address:
    get:
      tags: [User]
      summary: User addresses (primary/secondary)
      x-evidence: ["c2:279", "c5:215", "c5:223"]
      parameters:
        - { name: userId, in: path, required: true, schema: { type: integer } }
        - { name: include, in: query, required: false, schema: { type: string, example: address } }
      responses:
        "200": { description: "{user_addresses:[{id,address_id,primary,address:{address,city,zip_code,latitude,longitude,...}}]}" }
components:
  securitySchemes:
    accessToken:
      type: apiKey
      in: header
      name: X-Access-Token
  parameters:
    locale: { name: locale, in: query, schema: { type: string, example: en } }
    limit: { name: limit, in: query, schema: { type: integer, example: 30 } }
    nextCursor: { name: next_cursor, in: query, schema: { type: string }, description: "base64 cursor, e.g. `id::<n>`" }
    userId: { name: user_id, in: query, schema: { type: integer } }
    userIdPath: { name: userId, in: path, required: true, schema: { type: integer } }
    studioId: { name: studioId, in: path, required: true, schema: { type: integer, example: 1724 } }
  schemas:
    LocaleBody:
      type: object
      properties:
        locale: { type: string, example: en }
    AuthenticateRequest:
      type: object
      required: [authenticator, email, password]
      properties:
        authenticator: { type: string, example: PASSWORD }
        email: { type: string, example: "<EMAIL>" }
        password: { type: string, example: "<PASSWORD>" }
        locale: { type: string, example: en }
    AuthenticateResponse:
      type: object
      properties:
        user_id: { type: integer }
        user_created: { type: boolean }
        access_token: { type: string, description: 64 chars }
        refresh_token: { type: string, description: 64 chars }
        access_token_expires_at: { type: string, format: date-time }
        refresh_token_expires_at: { type: string, format: date-time }
        user_agent: { type: string }
    Metadata:
      type: object
      properties:
        pagination:
          type: object
          properties:
            count: { type: integer }
            limit: { type: integer }
            offset: { type: integer }
            next_offset: { type: integer }
            next_cursor: { type: string, nullable: true }
            prev_cursor: { type: string, nullable: true }
        cache_expire_seconds: { type: integer }
    Studio:
      type: object
      properties:
        id: { type: integer, example: 1724 }
        name: { type: string, example: STC Rosenlundsparken }
        photo: { type: string, example: z63qkfnoqry2dz7uzrkf }
        status: { type: string, example: LIVE }
        city_id: { type: integer, example: 1 }
        vicinity: { type: string, example: Södermalm }
        rating:
          type: object
          properties:
            total: { type: integer, example: 270 }
            average: { type: integer, example: 470, description: "rating × 100" }
            distribution: { type: object, additionalProperties: { type: integer } }
        remote: { type: boolean }
        tier_level: { type: integer }
        qr_code_id: { type: string, example: "stc:13994" }
        has_classes: { type: boolean }
        has_openings: { type: boolean }
        has_address: { type: boolean }
        time_zone: { type: string, example: Europe/Stockholm }
        integration_type: { type: string, nullable: true }
        integration_door_type: { type: string, example: brp, description: "Door system; \"parakey\" = doors open with the Parakey app (brp, wondr, zoezi, m3_fit, blx, ellions, tic_off also seen)" }
        parakey_config:
          type: object
          nullable: true
          description: "Studio detail only. Set for Parakey-connected studios; optional=true means the QR check-in works too."
          properties:
            id: { type: integer, example: 13 }
            optional: { type: boolean }
        deleted: { type: boolean }
        requires_additional_cost: { type: boolean }
        alert: { type: string, nullable: true, description: "Studio-level notice string (null in all captures)" }
        alerts:
          type: array
          description: "With include_alerts / include_studio_alerts. Active = start_date passed and end_date null or in the future."
          items: { $ref: "#/components/schemas/StudioAlert" }
        studio_addresses:
          type: array
          items:
            type: object
            properties:
              main: { type: boolean }
              address:
                type: object
                properties:
                  address: { type: string, example: "Bjälbogatan 26, 118 66 Stockholm, Sweden" }
                  city: { type: string }
                  country_code: { type: string }
                  latitude: { type: number }
                  longitude: { type: number }
        studio_categories:
          type: array
          items:
            type: object
            properties:
              main: { type: boolean }
              category:
                type: object
                properties:
                  id: { type: integer }
                  code: { type: string, example: gym }
                  title: { type: string, example: Gym }
        studio_photos:
          type: array
          items:
            type: object
            properties:
              main: { type: boolean }
              photo:
                type: object
                properties:
                  file_id: { type: string }
                  format: { type: string, example: JPG }
        visit_limit:
          type: object
          properties:
            description: { type: string, example: STC Unlimited }
            type: { type: string, example: GENERAL }
            items:
              type: array
              items:
                type: object
                properties:
                  label: { type: string, example: BASE }
                  limit: { type: integer, example: 0 }
                  period: { type: string, example: MONTHLY }
    Class:
      type: object
      properties:
        id: { type: integer }
        studio_id: { type: integer }
        presentation_studio_id: { type: integer }
        title: { type: string }
        description: { type: string, description: "Markdown (bold, lists, links)" }
        alert: { type: string, nullable: true, example: "Only women are welcome here. Please respect this.", description: "Class-level notice, shown in red under the description; no dates" }
        start_time: { type: string, format: date-time }
        end_time: { type: string, format: date-time }
        time_zone: { type: string }
        spots: { type: integer }
        available_spots: { type: integer }
        canceled: { type: boolean }
        instructor: { type: string, nullable: true, example: "**Millie Jabol - English speaking", description: "Free text; some booking systems prefix Markdown bold markers" }
        deleted: { type: boolean, description: "Soft-deleted; still returned by list calls unless deleted=false" }
        deleted_at: { type: string, format: date-time, nullable: true }
        bookable_earliest: { type: integer, example: 604800, description: "Seconds before start when booking opens (7 days for ~97% of classes, 3-14 days at a few studios). Logged-in responses only" }
        bookable_earliest_time: { type: string, format: date-time, description: "When booking opens = start_time - bookable_earliest. Logged-in responses only" }
        bookable_latest: { type: integer, example: 600, description: "Seconds before start when booking closes" }
        bookable_latest_time: { type: string, format: date-time }
        unbookable_latest: { type: integer, example: 43200, description: "Seconds before start after which cancelling is a late cancellation" }
        unbookable_latest_time: { type: string, format: date-time }
        master_id: { type: integer, nullable: true, description: "Series shared by the weekly occurrences of a class (one master can cover several weekly slots). Logged-in responses only" }
        auto_check_in: { type: boolean }
        early_check_in_minutes: { type: integer }
        late_check_in_minutes: { type: integer }
        booking_credit_costs:
          type: object
          description: Credits per tier
          properties:
            BASE: { type: integer }
            BLACK: { type: integer }
            EPIC: { type: integer }
            FREE: { type: integer }
    StudioAlert:
      type: object
      properties:
        id: { type: integer }
        studio_id: { type: integer }
        message_string_id: { type: integer }
        type: { type: string, example: INFO }
        order: { type: integer }
        show_on_web: { type: boolean }
        start_date: { type: string, format: date-time }
        end_date: { type: string, format: date-time, nullable: true, description: "null = open-ended" }
        start_day: { type: integer, example: 20231204 }
        end_day: { type: integer, example: 0, description: "0 when end_date is null" }
        message: { type: string, example: "Women only here! Please respect this." }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        deleted_at: { type: string, format: date-time, nullable: true }
    ClassCard:
      type: object
      properties:
        id: { type: integer, description: Class id }
        title: { type: string, example: Strength }
        photo: { type: string, description: Cloudinary file id }
        time_zone: { type: string, example: Europe/Stockholm }
        distance: { type: integer, description: "Metres from the original class's studio (0 = same studio)" }
        start_time: { type: string, format: date-time }
        end_time: { type: string, format: date-time }
        created_at: { type: string, format: date-time }
        studio:
          type: object
          properties:
            id: { type: integer }
            name: { type: string }
    Booking:
      type: object
      properties:
        id: { type: integer }
        user_id: { type: integer }
        studio_id: { type: integer }
        class_id: { type: integer, nullable: true }
        place_id: { type: integer, nullable: true }
        opening_hour_occurrence_id: { type: integer, nullable: true }
        checked_in_at: { type: string, format: date-time, nullable: true }
        booking_credits: { type: integer }
        integration_type: { type: string, nullable: true }
        deleted: { type: boolean }
        deleted_at: { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time }
        review_id: { type: integer, nullable: true, description: "Set once the class was rated (c8:467)" }
    CreateReviewRequest:
      type: object
      required: [rating, booking_id]
      properties:
        rating: { type: integer, minimum: 1, maximum: 5, example: 5 }
        public: { type: boolean, example: true, description: "false = anonymous" }
        booking_id: { type: integer }
        comment: { type: string, description: Optional (not in the captured request) }
        include_booking: { type: boolean, example: true }
        locale: { type: string, example: en }
    Review:
      type: object
      properties:
        id: { type: integer }
        rating: { type: integer }
        comment: { type: string, nullable: true }
        created_at: { type: string, format: date-time }
        public: { type: boolean }
        studio_id: { type: integer }
        class_id: { type: integer, nullable: true }
        user_id: { type: integer }
        booking: { $ref: "#/components/schemas/Booking" }
    CreateBookingRequest:
      type: object
      required: [class_id]
      properties:
        class_id: { type: integer }
        booked_via: { type: string, example: HOMESCREEN_SPOTLIGHT, description: Analytics source tag }
        include_user: { type: boolean }
        locale: { type: string, example: en }
    CheckInRequest:
      type: object
      required: [qr_code_id, meta]
      properties:
        locale: { type: string, example: en }
        qr_code_id: { type: string, example: "stc:13994" }
        meta:
          type: object
          properties:
            major: { type: string, example: "13994" }
            minor: { type: string, example: "118661" }
            door_id: { type: string, example: "118661" }
        include_class: { type: boolean, example: true }
    CheckInResponse:
      type: object
      properties:
        bookings: { type: array, items: { $ref: "#/components/schemas/Booking" } }
        payable_openings: { type: array, items: { type: object } }
        payable_opening_hours: { type: array, items: { type: object } }
        integration_door_data: { type: object, nullable: true }
