openapi: 3.0.3 info: title: Bookra API version: 0.2.0 description: > Remote-first booking API for Bookra. The Go backend owns business rules, scheduling logic, tenant isolation, and Stripe-backed plan enforcement. servers: - url: http://localhost:8080 tags: - name: Health - name: Auth - name: Admin - name: Public Booking - name: Dashboard - name: Tenant - name: Catalog - name: Billing - name: Calendar - name: SMS - name: Jobs paths: /healthz: get: tags: [Health] operationId: getHealth responses: "200": description: Service health content: application/json: schema: $ref: "#/components/schemas/HealthResponse" /v1/meta/config: get: tags: [Health] operationId: getPublicConfig responses: "200": description: Public runtime configuration content: application/json: schema: $ref: "#/components/schemas/PublicConfig" /v1/public/widget.js: get: tags: [Public Booking] operationId: getWidgetScript responses: "200": description: Embeddable widget script content: application/javascript: schema: type: string # ---------------- Auth ---------------- /v1/auth/register: post: tags: [Auth] operationId: register requestBody: required: true content: application/json: schema: type: object required: [email, password] properties: email: type: string format: email password: type: string minLength: 8 name: type: string responses: "201": description: User created content: application/json: schema: $ref: "#/components/schemas/AuthResponse" "400": description: Invalid request "409": description: Email already registered /v1/auth/login: post: tags: [Auth] operationId: login requestBody: required: true content: application/json: schema: type: object required: [email, password] properties: email: type: string format: email password: type: string responses: "200": description: Login succeeded content: application/json: schema: $ref: "#/components/schemas/AuthResponse" "401": description: Invalid credentials /v1/auth/magic-link: post: tags: [Auth] operationId: requestMagicLink requestBody: required: true content: application/json: schema: type: object required: [email] properties: email: type: string format: email responses: "200": description: Magic link issued (always 200 to prevent enumeration) /v1/auth/verify: post: tags: [Auth] operationId: verifyMagicLink requestBody: required: true content: application/json: schema: type: object required: [token] properties: token: type: string responses: "200": description: Token verified content: application/json: schema: $ref: "#/components/schemas/AuthResponse" "401": description: Invalid or expired token /v1/auth/refresh: post: tags: [Auth] operationId: refreshTokens requestBody: required: true content: application/json: schema: type: object required: [refreshToken] properties: refreshToken: type: string responses: "200": description: Tokens rotated content: application/json: schema: $ref: "#/components/schemas/AuthResponse" "401": description: Invalid refresh token # ---------------- Admin ---------------- /v1/admin/stats: get: tags: [Admin] operationId: getAdminStats security: - adminAuth: [] - bearerAuth: [] responses: "200": description: Platform statistics "403": description: Forbidden /v1/admin/tenants: get: tags: [Admin] operationId: listAdminTenants security: - adminAuth: [] - bearerAuth: [] responses: "200": description: All tenants "403": description: Forbidden /v1/admin/users: get: tags: [Admin] operationId: listAdminUsers security: - adminAuth: [] - bearerAuth: [] responses: "200": description: All users "403": description: Forbidden /v1/admin/users/{userID}/role: put: tags: [Admin] operationId: updateUserRole security: - adminAuth: [] - bearerAuth: [] parameters: - in: path name: userID required: true schema: type: string requestBody: required: true content: application/json: schema: type: object required: [role] properties: role: type: string enum: [owner, staff, admin] responses: "200": description: Role updated "403": description: Forbidden /v1/admin/trigger-trial-emails: post: tags: [Admin] operationId: triggerTrialEmails security: - adminAuth: [] - bearerAuth: [] responses: "200": description: Trial emails triggered "403": description: Forbidden # ---------------- Public Booking ---------------- /v1/public/tenants/{tenantSlug}/availability: get: tags: [Public Booking] operationId: getPublicAvailability parameters: - in: path name: tenantSlug required: true schema: type: string - in: query name: serviceId required: false schema: type: string format: uuid description: Restrict appointment slots to a single service - in: query name: staffId required: false schema: type: string format: uuid description: Restrict appointment slots to a single staff member - in: query name: date required: false schema: type: string format: date description: Return all slots for a single tenant-local day (YYYY-MM-DD) instead of the capped preview responses: "200": description: Availability for a tenant booking page content: application/json: schema: $ref: "#/components/schemas/PublicAvailabilityResponse" /v1/public/tenants/{tenantSlug}/catalog: get: tags: [Public Booking] operationId: getPublicCatalog parameters: - in: path name: tenantSlug required: true schema: type: string responses: "200": description: Bookable services and active staff for a tenant booking page content: application/json: schema: $ref: "#/components/schemas/PublicCatalogResponse" /v1/public/bookings: post: tags: [Public Booking] operationId: createBooking requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CreateBookingRequest" responses: "201": description: Booking created content: application/json: schema: $ref: "#/components/schemas/CreateBookingResponse" "409": description: Slot no longer available /v1/public/bookings/{reference}: get: tags: [Public Booking] operationId: getBookingByReference parameters: - in: path name: reference required: true schema: type: string - in: query name: token required: true schema: type: string description: Manage token issued at booking time responses: "200": description: Booking details content: application/json: schema: $ref: "#/components/schemas/CustomerBookingView" "403": description: Invalid manage token "404": description: Booking not found /v1/public/bookings/{reference}/reschedule: post: tags: [Public Booking] operationId: rescheduleBooking parameters: - in: path name: reference required: true schema: type: string - in: query name: token required: true schema: type: string description: Manage token issued at booking time requestBody: required: true content: application/json: schema: type: object required: [newStartsAt, newEndsAt] properties: newStartsAt: type: string format: date-time newEndsAt: type: string format: date-time reason: type: string responses: "200": description: Booking rescheduled "400": description: Invalid new window, non-reschedulable status, or class booking "403": description: Invalid manage token "409": description: New slot conflicts or booking already cancelled /v1/public/bookings/{reference}/sessions: get: tags: [Public Booking] operationId: listSessionMoveTargets summary: Upcoming sessions a class booking can move to description: >- Class bookings cannot reschedule to arbitrary windows — they move between sessions of the same template. Returns upcoming same-template sessions with live capacity info. parameters: - in: path name: reference required: true schema: type: string - in: query name: token required: true schema: type: string responses: "200": description: Move targets with capacity info content: application/json: schema: type: object properties: sessions: type: array items: $ref: "#/components/schemas/SessionMoveTarget" "400": description: Not a live class booking "403": description: Invalid manage token "404": description: Booking not found "409": description: Booking already cancelled /v1/public/bookings/{reference}/move: post: tags: [Public Booking] operationId: moveBookingToSession summary: Move a class booking to another session of the same template description: >- Retargets the booking to the target session. Confirmed bookings free their old seat (draining its waitlist); waitlisted bookings release their queue position. The booking is confirmed when the target has a free seat, otherwise it joins the target's waitlist. parameters: - in: path name: reference required: true schema: type: string - in: query name: token required: true schema: type: string requestBody: required: true content: application/json: schema: type: object required: [classSessionId] properties: classSessionId: type: string format: uuid responses: "200": description: Booking moved; status is the resulting booking state content: application/json: schema: type: object properties: status: type: string enum: [confirmed, waitlisted] "400": description: Not a live class booking, or target is not an upcoming same-template session "403": description: Invalid manage token "404": description: Booking not found "409": description: Booking already cancelled /v1/public/bookings/{reference}/cancel: post: tags: [Public Booking] operationId: cancelBooking parameters: - in: path name: reference required: true schema: type: string requestBody: required: true content: application/json: schema: type: object required: [token] properties: token: type: string responses: "200": description: Booking cancelled "403": description: Invalid manage token /v1/public/contact: post: tags: [Public Booking] operationId: submitContactForm requestBody: required: true content: application/json: schema: type: object required: [name, email, message] properties: name: type: string email: type: string format: email message: type: string responses: "200": description: Message accepted # ---------------- Dashboard ---------------- /v1/dashboard/summary: get: tags: [Dashboard] operationId: getDashboardSummary security: - bearerAuth: [] responses: "200": description: Tenant dashboard summary content: application/json: schema: $ref: "#/components/schemas/DashboardSummary" "401": description: Unauthorized # ---------------- Tenant ---------------- /v1/tenants/bootstrap: get: tags: [Tenant] operationId: getTenantBootstrap security: - bearerAuth: [] responses: "200": description: Tenant bootstrap payload for the authenticated user content: application/json: schema: $ref: "#/components/schemas/TenantBootstrap" "401": description: Unauthorized /v1/tenants/onboard: post: tags: [Tenant] operationId: onboardTenant security: - bearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/OnboardTenantRequest" responses: "201": description: Tenant created for authenticated user content: application/json: schema: $ref: "#/components/schemas/TenantBootstrap" "400": description: Invalid request "401": description: Unauthorized "409": description: Conflict /v1/tenants/brand: put: tags: [Tenant] operationId: updateTenantBrand security: - bearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/UpdateBrandRequest" responses: "200": description: Brand profile updated content: application/json: schema: $ref: "#/components/schemas/BrandProfile" "401": description: Unauthorized /v1/tenants/email-templates: get: tags: [Tenant] operationId: listEmailTemplates security: - bearerAuth: [] responses: "200": description: Tenant email templates content: application/json: schema: type: object required: [templates] properties: templates: type: array items: $ref: "#/components/schemas/EmailTemplate" "401": description: Unauthorized /v1/tenants/email-template: put: tags: [Tenant] operationId: updateEmailTemplate security: - bearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/UpdateEmailTemplateRequest" responses: "200": description: Template saved content: application/json: schema: $ref: "#/components/schemas/EmailTemplate" "400": description: Invalid request "401": description: Unauthorized # ---------------- Catalog ---------------- /v1/catalog/locations: get: tags: [Catalog] operationId: listLocations security: - bearerAuth: [] responses: "200": description: Tenant locations content: application/json: schema: type: array items: $ref: "#/components/schemas/Location" "401": description: Unauthorized post: tags: [Catalog] operationId: createLocation security: - bearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/UpsertLocationRequest" responses: "201": description: Location created content: application/json: schema: $ref: "#/components/schemas/Location" "400": description: Invalid request "403": description: Plan limit reached /v1/catalog/locations/{locationID}: put: tags: [Catalog] operationId: updateLocation security: - bearerAuth: [] parameters: - $ref: "#/components/parameters/locationID" requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/UpsertLocationRequest" responses: "200": description: Location updated content: application/json: schema: $ref: "#/components/schemas/Location" "404": description: Location not found delete: tags: [Catalog] operationId: deleteLocation security: - bearerAuth: [] parameters: - $ref: "#/components/parameters/locationID" responses: "200": description: Location deleted "404": description: Location not found /v1/catalog/services: get: tags: [Catalog] operationId: listServices security: - bearerAuth: [] responses: "200": description: Tenant services content: application/json: schema: type: array items: $ref: "#/components/schemas/Service" "401": description: Unauthorized post: tags: [Catalog] operationId: createService security: - bearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/UpsertServiceRequest" responses: "201": description: Service created content: application/json: schema: $ref: "#/components/schemas/Service" "400": description: Invalid request /v1/catalog/services/{serviceID}: put: tags: [Catalog] operationId: updateService security: - bearerAuth: [] parameters: - $ref: "#/components/parameters/serviceID" requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/UpsertServiceRequest" responses: "200": description: Service updated content: application/json: schema: $ref: "#/components/schemas/Service" "400": description: Invalid request "404": description: Service not found delete: tags: [Catalog] operationId: deleteService security: - bearerAuth: [] parameters: - $ref: "#/components/parameters/serviceID" responses: "200": description: Service deleted "404": description: Service not found /v1/catalog/staff: get: tags: [Catalog] operationId: listStaff security: - bearerAuth: [] responses: "200": description: Tenant staff content: application/json: schema: type: array items: $ref: "#/components/schemas/StaffMember" "401": description: Unauthorized post: tags: [Catalog] operationId: createStaff security: - bearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/UpsertStaffRequest" responses: "201": description: Staff member created content: application/json: schema: $ref: "#/components/schemas/StaffMember" "400": description: Invalid request "403": description: Plan limit reached /v1/catalog/staff/{staffID}: put: tags: [Catalog] operationId: updateStaff security: - bearerAuth: [] parameters: - $ref: "#/components/parameters/staffID" requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/UpsertStaffRequest" responses: "200": description: Staff member updated content: application/json: schema: $ref: "#/components/schemas/StaffMember" "400": description: Invalid request "404": description: Staff member not found delete: tags: [Catalog] operationId: deleteStaff security: - bearerAuth: [] parameters: - $ref: "#/components/parameters/staffID" responses: "200": description: Staff member deleted "404": description: Staff member not found /v1/catalog/bookings: post: tags: [Catalog] operationId: createDashboardBooking security: - bearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/DashboardBookingRequest" responses: "201": description: Booking created content: application/json: schema: $ref: "#/components/schemas/UpcomingBooking" "400": description: Invalid request "409": description: Conflict with existing booking /v1/catalog/bookings/{bookingID}/cancel: post: tags: [Catalog] operationId: cancelDashboardBooking security: - bearerAuth: [] parameters: - in: path name: bookingID required: true schema: type: string format: uuid responses: "200": description: Booking cancelled; freed class seats promote the waitlist "400": description: Booking already cancelled "404": description: Booking not found /v1/catalog/bookings/{bookingID}/promote: post: tags: [Catalog] operationId: promoteDashboardBooking security: - bearerAuth: [] parameters: - in: path name: bookingID required: true schema: type: string format: uuid requestBody: required: false content: application/json: schema: $ref: "#/components/schemas/PromoteBookingRequest" responses: "200": description: Waitlisted booking confirmed; customer notified "400": description: Booking is not on a waitlist "404": description: Booking not found "409": description: Session at capacity; retry with allowOverCapacity to override /v1/catalog/blocked-days: get: tags: [Catalog] operationId: listBlockedDays security: - bearerAuth: [] responses: "200": description: Blocked days content: application/json: schema: type: array items: $ref: "#/components/schemas/BlockedDay" post: tags: [Catalog] operationId: createBlockedDay security: - bearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/UpsertBlockedDayRequest" responses: "201": description: Blocked day created content: application/json: schema: $ref: "#/components/schemas/BlockedDay" /v1/catalog/blocked-days/{blockedDayID}: put: tags: [Catalog] operationId: updateBlockedDay security: - bearerAuth: [] parameters: - $ref: "#/components/parameters/blockedDayID" requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/UpsertBlockedDayRequest" responses: "200": description: Blocked day updated content: application/json: schema: $ref: "#/components/schemas/BlockedDay" "404": description: Not found delete: tags: [Catalog] operationId: deleteBlockedDay security: - bearerAuth: [] parameters: - $ref: "#/components/parameters/blockedDayID" responses: "200": description: Blocked day deleted "404": description: Not found /v1/catalog/customers: get: tags: [Catalog] operationId: listCustomers security: - bearerAuth: [] parameters: - in: query name: query schema: type: string - in: query name: limit schema: type: integer default: 50 responses: "200": description: Tenant customers content: application/json: schema: type: array items: $ref: "#/components/schemas/Customer" post: tags: [Catalog] operationId: createCustomer security: - bearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/UpsertCustomerRequest" responses: "201": description: Customer created content: application/json: schema: $ref: "#/components/schemas/Customer" /v1/catalog/customers/{customerID}: put: tags: [Catalog] operationId: updateCustomer security: - bearerAuth: [] parameters: - $ref: "#/components/parameters/customerID" requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/UpsertCustomerRequest" responses: "200": description: Customer updated content: application/json: schema: $ref: "#/components/schemas/Customer" "404": description: Not found delete: tags: [Catalog] operationId: deleteCustomer security: - bearerAuth: [] parameters: - $ref: "#/components/parameters/customerID" responses: "200": description: Customer deleted "404": description: Not found /v1/catalog/working-hours: get: tags: [Catalog] operationId: listWorkingHours security: - bearerAuth: [] responses: "200": description: Weekly working hours content: application/json: schema: type: array items: $ref: "#/components/schemas/WorkingHours" /v1/catalog/working-hours/{dayOfWeek}: put: tags: [Catalog] operationId: updateWorkingHours security: - bearerAuth: [] parameters: - in: path name: dayOfWeek required: true schema: type: integer minimum: 0 maximum: 6 requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/UpdateWorkingHoursRequest" responses: "200": description: Working hours updated "400": description: Invalid request /v1/catalog/staff/{staffID}/working-hours: get: tags: [Catalog] operationId: listStaffWorkingHours security: - bearerAuth: [] parameters: - in: path name: staffID required: true schema: type: string format: uuid responses: "200": description: Staff member's weekly working hours content: application/json: schema: type: array items: $ref: "#/components/schemas/WorkingHours" "404": description: Staff member not found /v1/catalog/staff/{staffID}/working-hours/{dayOfWeek}: put: tags: [Catalog] operationId: updateStaffWorkingHours security: - bearerAuth: [] parameters: - in: path name: staffID required: true schema: type: string format: uuid - in: path name: dayOfWeek required: true schema: type: integer minimum: 0 maximum: 6 requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/UpdateWorkingHoursRequest" responses: "200": description: Staff working hours updated "400": description: Invalid request "404": description: Staff member not found /v1/catalog/class-templates: get: tags: [Catalog] operationId: listClassTemplates security: - bearerAuth: [] responses: "200": description: Class templates content: application/json: schema: type: array items: $ref: "#/components/schemas/ClassTemplate" post: tags: [Catalog] operationId: createClassTemplate security: - bearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/UpsertClassTemplateRequest" responses: "201": description: Class template created content: application/json: schema: $ref: "#/components/schemas/ClassTemplate" "400": description: Invalid request /v1/catalog/class-templates/{templateID}: put: tags: [Catalog] operationId: updateClassTemplate security: - bearerAuth: [] parameters: - in: path name: templateID required: true schema: type: string format: uuid requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/UpsertClassTemplateRequest" responses: "200": description: Updated class template "400": description: Invalid input "404": description: Template not found delete: tags: [Catalog] operationId: deleteClassTemplate security: - bearerAuth: [] parameters: - in: path name: templateID required: true schema: type: string format: uuid responses: "200": description: Class template deleted "404": description: Class template not found /v1/catalog/class-sessions: get: tags: [Catalog] operationId: listClassSessions security: - bearerAuth: [] responses: "200": description: Upcoming class sessions content: application/json: schema: type: array items: $ref: "#/components/schemas/ClassSession" post: tags: [Catalog] operationId: createClassSession security: - bearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CreateClassSessionRequest" responses: "201": description: Class session created content: application/json: schema: $ref: "#/components/schemas/ClassSession" "400": description: Invalid request "404": description: Template or location not found /v1/catalog/class-sessions/{sessionID}: delete: tags: [Catalog] operationId: deleteClassSession security: - bearerAuth: [] parameters: - in: path name: sessionID required: true schema: type: string format: uuid responses: "200": description: Class session deleted "404": description: Class session not found /v1/catalog/class-sessions/{sessionID}/roster: get: tags: [Catalog] operationId: getSessionRoster security: - bearerAuth: [] parameters: - in: path name: sessionID required: true schema: type: string format: uuid responses: "200": description: Confirmed attendees and FIFO waitlist for the session content: application/json: schema: $ref: "#/components/schemas/SessionRoster" "404": description: Class session not found /v1/catalog/class-schedules: get: tags: [Catalog] operationId: listClassSchedules security: - bearerAuth: [] summary: Weekly recurring rules for class sessions description: > Each rule materializes concrete class_sessions for a rolling 28-day horizon in the tenant timezone. Sessions are written idempotently on read (list class sessions or public availability), so no background job is needed. Deleting a rule stops future materialization; sessions already created stay in place and keep their bookings. responses: "200": description: Recurring class schedules content: application/json: schema: type: array items: $ref: "#/components/schemas/ClassSchedule" post: tags: [Catalog] operationId: createClassSchedule security: - bearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CreateClassScheduleRequest" responses: "201": description: Class schedule created (sessions materialized immediately) content: application/json: schema: $ref: "#/components/schemas/ClassSchedule" "400": description: Invalid request "404": description: Template or location not found /v1/catalog/class-schedules/{scheduleID}: delete: tags: [Catalog] operationId: deleteClassSchedule security: - bearerAuth: [] parameters: - in: path name: scheduleID required: true schema: type: string format: uuid responses: "200": description: Class schedule deleted "404": description: Class schedule not found # ---------------- Calendar ---------------- /v1/calendar/feed: get: tags: [Calendar] operationId: getCalendarFeedInfo security: - bearerAuth: [] summary: Feed tokens for the tenant and every staff member description: >- Returns the read-only ICS feed token for the whole tenant plus one per staff member. Tokens are secrets — anyone holding a feed URL can read that calendar, same model as private Google/Outlook calendar links. responses: "200": description: Current feed tokens content: application/json: schema: $ref: "#/components/schemas/CalendarFeedInfo" "404": description: Tenant membership not found /v1/calendar/feed/rotate: post: tags: [Calendar] operationId: rotateCalendarFeedToken security: - bearerAuth: [] summary: Invalidate a feed URL and issue a fresh token requestBody: required: true content: application/json: schema: type: object required: [scope] properties: scope: type: string enum: [tenant, staff] staffId: type: string format: uuid description: Required when scope is staff; must belong to the tenant responses: "200": description: Newly issued token and feed path content: application/json: schema: $ref: "#/components/schemas/CalendarFeedEntry" "400": description: Invalid scope or foreign staff member "404": description: Tenant membership not found /v1/activity: get: tags: [Activity] operationId: listActivityEvents security: - bearerAuth: [] summary: Recent tenant activity feed description: >- Audit feed of booking lifecycle events — created, cancelled, rescheduled, moved, promoted — with the actor (staff member, customer, or system) for each change. Newest first. parameters: - name: limit in: query schema: type: integer default: 50 maximum: 100 responses: "200": description: Activity events, newest first content: application/json: schema: $ref: "#/components/schemas/ActivityFeedResponse" "404": description: Tenant membership not found /v1/activity/read: post: tags: [Activity] operationId: markActivityRead security: - bearerAuth: [] summary: Mark all activity events as read description: >- Persists the caller's read cursor — activity events at or before the returned timestamp count as read for this user on subsequent loads. responses: "200": description: Read cursor persisted content: application/json: schema: type: object required: [readAt] properties: readAt: type: string format: date-time "404": description: Tenant membership not found /v1/metrics/staff: get: tags: [Metrics] operationId: getStaffMetrics security: - bearerAuth: [] summary: Per-staff appointment metrics over a trailing window description: >- Bookings, completions, cancellations, utilization, and revenue per staff member. Only appointment bookings carry a staff assignment — class attendance is excluded by design. Every active staff member appears, including zero rows. parameters: - name: days in: query schema: type: integer default: 30 maximum: 365 responses: "200": description: Per-staff metrics content: application/json: schema: $ref: "#/components/schemas/StaffMetricsResponse" "404": description: Tenant membership not found /v1/export/bookings.csv: get: tags: [Export] operationId: exportBookingsCsv security: - bearerAuth: [] summary: Export tenant bookings as CSV description: >- Full booking export for accounting and backup — UTF-8 with BOM so Czech diacritics open correctly in Excel. Optional `from`/`to` (YYYY-MM-DD) bounds the export window; `to` is inclusive. parameters: - name: from in: query schema: type: string format: date - name: to in: query schema: type: string format: date responses: "200": description: CSV file download content: text/csv: schema: type: string "404": description: Tenant membership not found /v1/export/customers.csv: get: tags: [Export] operationId: exportCustomersCsv security: - bearerAuth: [] summary: Export tenant customers as CSV description: Full customer list export — UTF-8 with BOM for Excel. responses: "200": description: CSV file download content: text/csv: schema: type: string "404": description: Tenant membership not found /v1/export/staff-report.csv: get: tags: [Export] operationId: exportStaffReportCsv security: - bearerAuth: [] summary: Export per-staff performance report as CSV description: >- Summarized per-staff metrics — bookings, completed, cancelled, booked and capacity minutes, capacity-based utilization, revenue — over a trailing window. Same aggregation as `GET /v1/metrics/staff`. parameters: - name: days in: query schema: type: integer default: 30 maximum: 365 responses: "200": description: CSV file download content: text/csv: schema: type: string "404": description: Tenant membership not found /v1/export/service-report.csv: get: tags: [Export] operationId: exportServiceReportCsv security: - bearerAuth: [] summary: Export per-service booking report as CSV description: >- Bookings, cancellations, and revenue aggregated per catalog service over a trailing window. Class bookings roll into a final "class sessions" row when present. parameters: - name: days in: query schema: type: integer default: 30 maximum: 365 responses: "200": description: CSV file download content: text/csv: schema: type: string "404": description: Tenant membership not found /v1/public/calendar/{token}: get: tags: [Calendar] operationId: getCalendarFeed summary: Read-only ICS calendar feed description: >- Token-as-secret calendar subscription endpoint. `{token}` may carry a `.ics` suffix, which is ignored. The feed covers bookings from 30 days back to 180 days ahead; cancelled bookings appear with STATUS:CANCELLED and waitlisted entries as TENTATIVE/TRANSPARENT. parameters: - in: path name: token required: true schema: type: string responses: "200": description: iCalendar document content: text/calendar: schema: type: string "404": description: Unknown or rotated feed token # ---------------- Billing ---------------- /v1/billing/subscription: get: tags: [Billing] operationId: getSubscriptionSnapshot security: - bearerAuth: [] responses: "200": description: Current subscription snapshot and entitlements content: application/json: schema: $ref: "#/components/schemas/SubscriptionSnapshot" "401": description: Unauthorized /v1/billing/checkout: post: tags: [Billing] operationId: createBillingCheckout security: - bearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CheckoutSessionRequest" responses: "200": description: Stripe checkout session content: application/json: schema: $ref: "#/components/schemas/CheckoutLaunchResponse" "400": description: Invalid request "401": description: Unauthorized "503": description: Billing provider unavailable /v1/billing/refresh: post: tags: [Billing] operationId: refreshSubscriptionSnapshot security: - bearerAuth: [] responses: "200": description: Refreshed snapshot content: application/json: schema: $ref: "#/components/schemas/SubscriptionSnapshot" "401": description: Unauthorized "503": description: Billing provider unavailable /v1/billing/portal: post: tags: [Billing] operationId: createBillingPortalSession security: - bearerAuth: [] responses: "200": description: Stripe customer portal session content: application/json: schema: $ref: "#/components/schemas/PortalSessionResponse" "400": description: Billing customer missing "401": description: Unauthorized "503": description: Billing provider unavailable /v1/webhooks/stripe: post: tags: [Billing] operationId: handleStripeWebhook requestBody: required: true content: application/json: schema: type: object responses: "200": description: Webhook accepted "400": description: Invalid signature or payload # ---------------- SMS ---------------- /v1/sms/settings: get: tags: [SMS] operationId: getSmsSettings security: - bearerAuth: [] responses: "200": description: SMS settings and current-month usage post: tags: [SMS] operationId: updateSmsSettings security: - bearerAuth: [] requestBody: required: true content: application/json: schema: type: object properties: enabled: type: boolean sender: type: string monthlyLimit: type: integer responses: "200": description: SMS settings saved /v1/sms/send: post: tags: [SMS] operationId: sendSms security: - bearerAuth: [] requestBody: required: true content: application/json: schema: type: object required: [to, message] properties: to: type: string message: type: string responses: "200": description: SMS queued /v1/sms/usage: get: tags: [SMS] operationId: getSmsUsage security: - bearerAuth: [] parameters: - in: query name: month schema: type: string example: "2026-09" responses: "200": description: Usage for the month /v1/sms/history: get: tags: [SMS] operationId: getSmsHistory security: - bearerAuth: [] responses: "200": description: Recent SMS logs /v1/sms/invoices: get: tags: [SMS] operationId: listSmsInvoices security: - bearerAuth: [] responses: "200": description: Monthly invoice reports # ---------------- Jobs ---------------- /v1/internal/jobs/reminders/dispatch: post: tags: [Jobs] operationId: dispatchReminderJobs security: - jobRunnerKey: [] requestBody: required: false content: application/json: schema: $ref: "#/components/schemas/DispatchReminderJobsRequest" responses: "200": description: Reminder jobs dispatched content: application/json: schema: $ref: "#/components/schemas/DispatchReminderJobsResponse" "401": description: Unauthorized /v1/internal/jobs/sms/invoices: post: tags: [Jobs] operationId: generateSmsInvoices security: - jobRunnerKey: [] responses: "200": description: Monthly SMS invoices generated "401": description: Unauthorized components: securitySchemes: bearerAuth: type: http scheme: bearer bearerFormat: JWT jobRunnerKey: type: apiKey in: header name: X-Bookra-Job-Key adminAuth: type: apiKey in: header name: X-Admin-Key description: Requires matching X-Admin-Email header as well. parameters: locationID: in: path name: locationID required: true schema: type: string format: uuid blockedDayID: in: path name: blockedDayID required: true schema: type: string format: uuid customerID: in: path name: customerID required: true schema: type: string format: uuid serviceID: in: path name: serviceID required: true schema: type: string format: uuid staffID: in: path name: staffID required: true schema: type: string format: uuid schemas: HealthResponse: type: object required: [status, environment] properties: status: type: string example: ok environment: type: string example: staging databaseConfigured: type: boolean PublicConfig: type: object required: [environment, neonAuthEnabled, apiUrl, demoMode] properties: environment: type: string neonAuthEnabled: type: boolean apiUrl: type: string format: uri demoMode: type: boolean AuthResponse: type: object required: [accessToken, refreshToken] properties: accessToken: type: string refreshToken: type: string expiresIn: type: integer user: type: object properties: id: type: string email: type: string name: type: string TimeSlot: type: object required: [startsAt, endsAt, mode] properties: serviceId: type: string format: uuid nullable: true classSessionId: type: string format: uuid nullable: true staffId: type: string format: uuid nullable: true staffName: type: string nullable: true locationId: type: string format: uuid nullable: true startsAt: type: string format: date-time endsAt: type: string format: date-time mode: type: string enum: [appointment, class] label: type: string remainingCapacity: type: integer nullable: true PublicAvailabilityResponse: type: object required: [tenantSlug, timezone, locale, slots] properties: tenantSlug: type: string timezone: type: string locale: type: string enum: [cs, en] slots: type: array items: $ref: "#/components/schemas/TimeSlot" PublicCatalogResponse: type: object required: [tenantSlug, services, staff] properties: tenantSlug: type: string services: type: array items: $ref: "#/components/schemas/PublicService" staff: type: array items: $ref: "#/components/schemas/PublicStaff" IntakeField: type: object required: [key, label, type] properties: key: type: string description: Slug key (lowercase alnum + underscore); JSON key in booking intake answers label: type: string description: Customer-facing question text type: type: string enum: [text, yesno] required: type: boolean PublicService: type: object required: [id, name, durationMinutes, priceCents] properties: id: type: string format: uuid name: type: string durationMinutes: type: integer priceCents: type: integer intakeFields: type: array items: $ref: "#/components/schemas/IntakeField" PublicStaff: type: object required: [id, displayName] properties: id: type: string format: uuid displayName: type: string CreateBookingRequest: type: object required: [tenantSlug, bookingMode, customerName, customerEmail, startsAt, endsAt] properties: tenantSlug: type: string bookingMode: type: string enum: [appointment, class] serviceId: type: string format: uuid classSessionId: type: string format: uuid staffId: type: string format: uuid locationId: type: string format: uuid customerName: type: string customerEmail: type: string format: email notes: type: string intake: type: object additionalProperties: type: string description: Structured intake answers keyed by the service's declared intakeFields keys startsAt: type: string format: date-time endsAt: type: string format: date-time CreateBookingResponse: type: object required: [bookingId, reference, status] properties: bookingId: type: string format: uuid reference: type: string status: type: string enum: [confirmed, waitlisted] manageToken: type: string description: Token required to view/reschedule/cancel the booking DashboardKPI: type: object required: [code, label, value] properties: code: type: string label: type: string value: type: string change: type: string trend: type: string enum: [up, down, neutral] DashboardSummary: type: object required: [tenantName, tenantSlug, locale, timezone, planCode, publicBookingUrl, setupCompletion, kpis] properties: tenantName: type: string tenantSlug: type: string locale: type: string timezone: type: string planCode: type: string publicBookingUrl: type: string setupCompletion: type: integer kpis: type: array items: $ref: "#/components/schemas/DashboardKPI" upcomingBookings: type: array items: $ref: "#/components/schemas/UpcomingBooking" widgetSnippets: type: array items: $ref: "#/components/schemas/WidgetSnippet" tracking: $ref: "#/components/schemas/TrackingStatus" UpcomingBooking: type: object properties: id: type: string format: uuid reference: type: string customerName: type: string customerEmail: type: string service: type: string location: type: string startsAt: type: string format: date-time endsAt: type: string format: date-time status: type: string label: type: string notes: type: string intake: type: object additionalProperties: type: string description: Structured intake answers keyed by the service's declared intakeFields keys classSessionId: type: string format: uuid waitlistPosition: type: integer description: 1-based queue spot for waitlisted class bookings; absent otherwise CustomerBookingView: type: object properties: reference: type: string customerName: type: string customerEmail: type: string service: type: string businessName: type: string tenantSlug: type: string serviceId: type: string format: uuid staffId: type: string format: uuid classSessionId: type: string format: uuid description: Present for session-bound bookings, which cannot be rescheduled to arbitrary times startsAt: type: string format: date-time endsAt: type: string format: date-time location: type: string status: type: string notes: type: string intake: type: object additionalProperties: type: string waitlistPosition: type: integer description: 1-based queue spot for waitlisted class bookings; absent otherwise SessionMoveTarget: type: object properties: sessionId: type: string format: uuid title: type: string startsAt: type: string format: date-time endsAt: type: string format: date-time location: type: string capacity: type: integer confirmedCount: type: integer waitlistCount: type: integer remaining: type: integer description: Free confirmed seats; 0 or negative means the move lands on the waitlist CalendarFeedEntry: type: object properties: token: type: string description: Feed secret — embed in the subscription URL feedPath: type: string description: API-relative path ending in .ics; prefix with the API base URL StaffCalendarFeedEntry: type: object properties: staffId: type: string format: uuid displayName: type: string token: type: string feedPath: type: string CalendarFeedInfo: type: object properties: tenant: $ref: "#/components/schemas/CalendarFeedEntry" staff: type: array items: $ref: "#/components/schemas/StaffCalendarFeedEntry" ActivityEvent: type: object required: [id, actorKind, action, entityType, summary, createdAt] properties: id: type: string format: uuid actorKind: type: string enum: [staff, customer, system] description: Who performed the change — dashboard user, self-service customer, or an automatic transition actorName: type: string action: type: string description: Dotted verb, e.g. booking.created, booking.cancelled, booking.promoted entityType: type: string entityId: type: string format: uuid summary: type: string description: Human-readable label, e.g. "Alice Johnson — Relaxation massage" createdAt: type: string format: date-time ActivityFeedResponse: type: object required: [events, unreadCount] properties: events: type: array items: $ref: "#/components/schemas/ActivityEvent" unreadCount: type: integer description: Events newer than the caller's persisted read cursor readAt: type: string format: date-time description: Caller's read cursor; absent until the first mark-read StaffMetric: type: object required: [staffId, displayName, bookings, completed, cancelled, bookedMinutes, capacityMinutes, utilizationPercent, revenueCents] properties: staffId: type: string format: uuid displayName: type: string bookings: type: integer completed: type: integer cancelled: type: integer bookedMinutes: type: integer description: Sum of non-cancelled booking durations in the window capacityMinutes: type: integer description: Sum of the member's availability-rule minutes across the window, evaluated on local dates in the tenant timezone utilizationPercent: type: integer description: bookedMinutes / capacityMinutes as a percentage; 0 when the member has no availability rules revenueCents: type: integer format: int64 description: Sum of linked service prices for non-cancelled bookings StaffMetricsResponse: type: object required: [from, to, staff] properties: from: type: string format: date-time to: type: string format: date-time staff: type: array items: $ref: "#/components/schemas/StaffMetric" DashboardBookingRequest: type: object required: [customerName, customerEmail, startsAt] properties: customerName: type: string customerEmail: type: string format: email customerPhone: type: string serviceId: type: string format: uuid locationId: type: string format: uuid startsAt: type: string format: date-time notes: type: string WidgetSnippet: type: object properties: kind: type: string code: type: string TrackingStatus: type: object properties: provider: type: string connected: type: boolean siteId: type: string message: type: string BrandProfile: type: object properties: name: type: string siteUrl: type: string logoUrl: type: string primaryColor: type: string UpdateBrandRequest: type: object properties: name: type: string siteUrl: type: string logoUrl: type: string primaryColor: type: string EmailTemplate: type: object properties: type: type: string enum: [confirmation, reminder, reschedule, cancellation] subject: type: string bodyText: type: string isEnabled: type: boolean UpdateEmailTemplateRequest: type: object required: [type] properties: type: type: string enum: [confirmation, reminder, reschedule, cancellation] subject: type: string body: type: string isEnabled: type: boolean TenantBootstrap: type: object required: [tenantId, tenantName, preset, locale, timezone, currentUser] properties: tenantId: type: string format: uuid tenantName: type: string tenantSlug: type: string preset: type: string locale: type: string timezone: type: string planCode: type: string onboardingCompleted: type: boolean brand: $ref: "#/components/schemas/BrandProfile" currentUser: type: object required: [subject, role] properties: subject: type: string email: type: string format: email name: type: string role: type: string OnboardTenantRequest: type: object required: [name, slug, preset, locale, timezone] properties: name: type: string slug: type: string preset: type: string enum: [salon, clinic, massage, repair, studio] locale: type: string enum: [cs, en] timezone: type: string brand: $ref: "#/components/schemas/BrandProfile" locationName: type: string bookingDefaults: $ref: "#/components/schemas/BookingDefaultsRequest" availabilityBlocks: type: array items: $ref: "#/components/schemas/AvailabilityBlockRequest" teamInvites: type: array items: $ref: "#/components/schemas/TeamInviteRequest" BookingDefaultsRequest: type: object properties: serviceName: type: string durationMinutes: type: integer bufferBeforeMinutes: type: integer bufferAfterMinutes: type: integer cancelWindowHours: type: integer AvailabilityBlockRequest: type: object required: [dayOfWeek, startsLocal, endsLocal] properties: dayOfWeek: type: integer minimum: 1 maximum: 7 startsLocal: type: string example: "09:00" endsLocal: type: string example: "17:00" busy: type: boolean TeamInviteRequest: type: object required: [email] properties: email: type: string format: email role: type: string Location: type: object required: [id, name] properties: id: type: string format: uuid name: type: string type: type: string enum: [room, private, hall] capacity: type: integer address: type: string UpsertLocationRequest: type: object required: [name] properties: name: type: string type: type: string enum: [room, private, hall] capacity: type: integer address: type: string Service: type: object required: [id, tenantId, name, durationMinutes, bufferBeforeMinutes, bufferAfterMinutes, priceCents] properties: id: type: string format: uuid tenantId: type: string format: uuid name: type: string durationMinutes: type: integer description: Appointment length in minutes (1-1440) bufferBeforeMinutes: type: integer description: Blocked time before the appointment (0-480) bufferAfterMinutes: type: integer description: Blocked time after the appointment (0-480) priceCents: type: integer description: Price in the tenant's currency minor units intakeFields: type: array items: $ref: "#/components/schemas/IntakeField" description: Structured intake questions asked at booking time (max 8) createdAt: type: string format: date-time UpsertServiceRequest: type: object required: [name, durationMinutes] properties: name: type: string durationMinutes: type: integer bufferBeforeMinutes: type: integer bufferAfterMinutes: type: integer priceCents: type: integer intakeFields: type: array items: $ref: "#/components/schemas/IntakeField" StaffMember: type: object required: [id, tenantId, displayName, active] properties: id: type: string format: uuid tenantId: type: string format: uuid locationId: type: string format: uuid nullable: true displayName: type: string active: type: boolean createdAt: type: string format: date-time UpsertStaffRequest: type: object required: [displayName] properties: displayName: type: string locationId: type: string format: uuid nullable: true description: Omit to keep the current assignment; empty string clears it active: type: boolean BlockedDay: type: object required: [id, date, type] properties: id: type: string format: uuid date: type: string format: date-time type: type: string enum: [closure, holiday, maintenance] reason: type: string UpsertBlockedDayRequest: type: object required: [date, type] properties: date: type: string format: date-time type: type: string enum: [closure, holiday, maintenance] reason: type: string Customer: type: object required: [id, name, email] properties: id: type: string format: uuid name: type: string email: type: string format: email phone: type: string notes: type: string bookingsCount: type: integer lastBookingAt: type: string format: date-time nullable: true UpsertCustomerRequest: type: object required: [name, email] properties: name: type: string email: type: string format: email phone: type: string notes: type: string WorkingHours: type: object properties: dayOfWeek: type: integer open: type: string example: "09:00" close: type: string example: "18:00" isOpen: type: boolean UpdateWorkingHoursRequest: type: object properties: open: type: string example: "09:00" close: type: string example: "18:00" isOpen: type: boolean ClassTemplate: type: object required: [id, tenantId, title, durationMinutes, capacity, createdAt] properties: id: type: string format: uuid tenantId: type: string format: uuid title: type: string durationMinutes: type: integer capacity: type: integer createdAt: type: string format: date-time UpsertClassTemplateRequest: type: object required: [title, durationMinutes, capacity] properties: title: type: string durationMinutes: type: integer capacity: type: integer ClassSession: type: object required: [id, tenantId, templateId, templateTitle, startsAt, endsAt, capacity, bookedCount, waitlistCount] properties: id: type: string format: uuid tenantId: type: string format: uuid templateId: type: string format: uuid templateTitle: type: string locationId: type: string format: uuid locationName: type: string startsAt: type: string format: date-time endsAt: type: string format: date-time capacity: type: integer bookedCount: type: integer waitlistCount: type: integer scheduleId: type: string format: uuid description: Recurring rule that generated this session; absent for manual sessions SessionRosterEntry: type: object required: [bookingId, reference, customerName, customerEmail, status, createdAt] properties: bookingId: type: string format: uuid reference: type: string customerName: type: string customerEmail: type: string customerPhone: type: string status: type: string waitlistPosition: type: integer createdAt: type: string format: date-time SessionRoster: type: object required: [sessionId, templateTitle, startsAt, endsAt, capacity, confirmed, waitlist] properties: sessionId: type: string format: uuid templateTitle: type: string locationName: type: string startsAt: type: string format: date-time endsAt: type: string format: date-time capacity: type: integer confirmed: type: array items: $ref: "#/components/schemas/SessionRosterEntry" waitlist: type: array items: $ref: "#/components/schemas/SessionRosterEntry" PromoteBookingRequest: type: object properties: allowOverCapacity: type: boolean description: Confirm even when the session is full (deliberate overbooking) CreateClassSessionRequest: type: object required: [templateId, startsAt] properties: templateId: type: string format: uuid locationId: type: string format: uuid startsAt: type: string format: date-time capacity: type: integer ClassSchedule: type: object required: [id, tenantId, templateId, templateTitle, dayOfWeek, startsLocal, active] properties: id: type: string format: uuid tenantId: type: string format: uuid templateId: type: string format: uuid templateTitle: type: string locationId: type: string format: uuid locationName: type: string dayOfWeek: type: integer minimum: 0 maximum: 6 description: 0=Sunday ... 6=Saturday startsLocal: type: string description: Tenant-local start time, HH:MM:SS active: type: boolean CreateClassScheduleRequest: type: object required: [templateId, dayOfWeek, startsLocal] properties: templateId: type: string format: uuid locationId: type: string format: uuid dayOfWeek: type: integer minimum: 0 maximum: 6 description: 0=Sunday ... 6=Saturday startsLocal: type: string description: Tenant-local start time, HH:MM PlanEntitlements: type: object required: [maxLocations, maxStaff, emailReminders, advancedReporting, widgetEmbedding, umamiTracking] properties: maxLocations: type: integer maxStaff: type: integer maxBookingsMonth: type: integer emailReminders: type: boolean widgetEmbedding: type: boolean umamiTracking: type: boolean advancedReporting: type: boolean SubscriptionSnapshot: type: object required: [tenantId, provider, customerId, subscriptionId, status, planCode, currency, priceId, cancelAtPeriodEnd, entitlements, displayPrices, trialDays, checkoutUrlAvailable, syncAvailable, portalAvailable] properties: tenantId: type: string format: uuid provider: type: string example: stripe customerId: type: string subscriptionId: type: string status: type: string planCode: type: string currency: type: string enum: [czk, usd, eur] priceId: type: string cancelAtPeriodEnd: type: boolean currentPeriodStart: type: string format: date-time nullable: true currentPeriodEnd: type: string format: date-time nullable: true paymentMethodBrand: type: string paymentMethodLast4: type: string entitlements: $ref: "#/components/schemas/PlanEntitlements" displayPrices: type: array items: $ref: "#/components/schemas/PlanDisplayPrice" trialDays: type: integer lastSyncedAt: type: string format: date-time nullable: true checkoutUrlAvailable: type: boolean syncAvailable: type: boolean portalAvailable: type: boolean CheckoutSessionRequest: type: object required: [planCode] properties: planCode: type: string enum: [starter, pro, business] description: The plan to subscribe to currency: type: string enum: [czk, usd] description: Currency for the subscription billingInterval: type: string enum: [monthly, yearly] default: monthly description: Billing interval. Yearly gets 17% discount. PlanDisplayPrice: type: object required: [currency, amountCents, formatted] properties: currency: type: string enum: [czk, usd] amountCents: type: integer description: Monthly price in cents formatted: type: string description: Formatted monthly price string yearlyAmountCents: type: integer description: Yearly price in cents (17% discount) yearlyFormatted: type: string description: Formatted yearly price string yearlySavings: type: string description: Description of yearly savings yearlySavingsPercent: type: integer description: Percentage saved with yearly billing CheckoutLaunchResponse: type: object description: Stripe checkout launch payload — redirect the user to checkoutUrl. properties: checkoutUrl: type: string format: uri description: Stripe checkout URL (redirect the user to this URL) successRedirectUrl: type: string format: uri description: URL to redirect after successful checkout cancelRedirectUrl: type: string format: uri description: URL to redirect after cancelled checkout PortalSessionResponse: type: object required: [url] properties: url: type: string format: uri DispatchReminderJobsRequest: type: object properties: limit: type: integer minimum: 1 maximum: 200 DispatchReminderJobsResponse: type: object required: [processedCount, sentCount, failedCount, skippedCount] properties: processedCount: type: integer sentCount: type: integer failedCount: type: integer skippedCount: type: integer