openapi: 3.1.0 info: title: WebScheduler API version: "1.0.0" description: > The WebScheduler REST API for external integrations. All endpoints are versioned under `/api/v1`. ## Authentication Every write endpoint (and most reads) requires a per-user Bearer token. Issue one with the CLI: ``` php spark api:key create --user --name "Zapier production" ``` The full token (`xsk__`) is shown **once** at creation and is not recoverable. Send it on every request: ``` Authorization: Bearer xsk_ab12cd34ef56_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX ``` Tokens are bound to a user; the request runs with that user's roles, so role-gated endpoints (creating providers, editing business hours) require a key bound to a user who holds the required role. There is no shared API secret. ## Response envelope Success responses wrap the resource in `data` with optional `meta` (pagination, timestamps). Errors return an `error` object with a stable machine-readable `code`. See the `SuccessEnvelope` / `ErrorEnvelope` schemas. ## Pagination, sorting, search List endpoints accept `page`, `length` (page size, max 100), `sort` (`field:asc|desc`) and, where supported, `q` (free-text). Pagination metadata is returned in `meta`. ## Rate limiting Token requests are limited to 120 requests per 60-second window per key. Exceeding it returns `429` with a `Retry-After` header (seconds). servers: - url: https://your-webscheduler-domain/api/v1 description: > Your WebScheduler instance. WebScheduler is self-hosted, so replace the host with the domain where it is installed. - url: /api/v1 description: Same-origin base path (used by the in-app docs portal) security: - bearerAuth: [] tags: - name: Appointments - name: Availability - name: Services - name: Categories - name: Providers - name: Customers - name: Business Hours - name: Locations components: securitySchemes: bearerAuth: type: http scheme: bearer description: > Opaque per-user API key (`xsk__`). NOT a JWT — do not attempt to decode it. Issued via `php spark api:key`. parameters: Page: name: page in: query required: false schema: { type: integer, minimum: 1, default: 1 } description: 1-based page number. Length: name: length in: query required: false schema: { type: integer, minimum: 1, maximum: 100, default: 25 } description: Items per page (server-capped at 100). Sort: name: sort in: query required: false schema: { type: string, example: "name:asc" } description: "Sort as `field:direction`, e.g. `created_at:desc`." Query: name: q in: query required: false schema: { type: string } description: Free-text filter (endpoints that support search). schemas: SuccessEnvelope: type: object required: [data] properties: data: description: The resource payload (object or array). meta: type: object description: Pagination, timestamps and other non-resource metadata. example: data: {} meta: {} ErrorEnvelope: type: object required: [error] properties: error: type: object required: [message] properties: message: { type: string, description: Human-readable summary. } code: type: string description: Stable machine-readable identifier. enum: - unauthorized - forbidden - rate_limited - NOT_FOUND - BAD_REQUEST - VALIDATION_ERROR - UNPROCESSABLE_ENTITY - SERVER_ERROR details: description: Optional structured info (field errors, etc.). example: error: message: "Validation failed" code: "VALIDATION_ERROR" details: { email: "Email address is not valid." } PaginationMeta: type: object properties: page: { type: integer } per_page: { type: integer } total: { type: integer } total_pages: { type: integer } sort: { type: string } timestamp: { type: string, format: date-time } Appointment: type: object properties: id: { type: integer } providerId: { type: integer } serviceId: { type: integer } customerId: { type: integer } start: { type: string, format: date-time } end: { type: string, format: date-time } status: { type: string, example: "confirmed" } notes: { type: string, nullable: true } AppointmentCreate: type: object required: [name, email, providerId, serviceId, date, start] properties: name: { type: string } email: { type: string, format: email } phone: { type: string, nullable: true } providerId: { type: integer } serviceId: { type: integer } date: { type: string, format: date } start: { type: string, example: "09:00" } notes: { type: string, nullable: true } Service: type: object properties: id: { type: integer } name: { type: string } description: { type: string, nullable: true } durationMin: { type: integer } price: { type: number, nullable: true } active: { type: boolean } ServiceWrite: type: object required: [name, durationMin] properties: name: { type: string } description: { type: string, nullable: true } durationMin: { type: integer, minimum: 1 } price: { type: number, nullable: true } categoryId: { type: integer, nullable: true } active: { type: boolean, default: true } Category: type: object properties: id: { type: integer } name: { type: string } description: { type: string, nullable: true } color: { type: string, nullable: true, example: "#3B82F6" } active: { type: boolean } CategoryWrite: type: object required: [name] properties: name: { type: string } description: { type: string, nullable: true } color: { type: string, nullable: true, example: "#3B82F6" } active: { type: boolean, default: true } Provider: type: object properties: id: { type: integer } name: { type: string } email: { type: string, nullable: true } phone: { type: string, nullable: true } slug: { type: string, nullable: true } color: { type: string, nullable: true } active: { type: boolean } profile_image: { type: string, nullable: true } ProviderWrite: type: object required: [name, email] properties: name: { type: string } email: { type: string, format: email } phone: { type: string, nullable: true } password: { type: string, nullable: true } color: { type: string, nullable: true } slug: { type: string, nullable: true } schedule: type: array description: Optional weekly working-hours entries. items: { type: object } Customer: type: object properties: id: { type: integer } firstName: { type: string } lastName: { type: string } email: { type: string, nullable: true } phone: { type: string, nullable: true } address: { type: string, nullable: true } notes: { type: string, nullable: true } createdAt: { type: string, format: date-time, nullable: true } CustomerWrite: type: object required: [firstName] properties: firstName: { type: string } lastName: { type: string, nullable: true } name: type: string nullable: true description: Combined name; used only when firstName/lastName are absent. email: { type: string, format: email, nullable: true } phone: { type: string, nullable: true } address: { type: string, nullable: true } notes: { type: string, nullable: true } BusinessHoursWrite: type: object required: [workStart, workEnd] properties: workStart: { type: string, example: "09:00", description: "HH:MM (24-hour)." } workEnd: { type: string, example: "17:00", description: "HH:MM (24-hour)." } Location: type: object properties: id: { type: integer } name: { type: string } address: { type: string, nullable: true } is_primary: { type: boolean } responses: Unauthorized: description: Missing or invalid credentials. content: application/json: schema: { $ref: '#/components/schemas/ErrorEnvelope' } Forbidden: description: Authenticated but lacks the required role. content: application/json: schema: { $ref: '#/components/schemas/ErrorEnvelope' } NotFound: description: Resource not found. content: application/json: schema: { $ref: '#/components/schemas/ErrorEnvelope' } ValidationError: description: The payload failed validation. content: application/json: schema: { $ref: '#/components/schemas/ErrorEnvelope' } RateLimited: description: Too many requests for this key in the current window. headers: Retry-After: schema: { type: integer } description: Seconds to wait before retrying. content: application/json: schema: { $ref: '#/components/schemas/ErrorEnvelope' } Success: description: Success. content: application/json: schema: { $ref: '#/components/schemas/SuccessEnvelope' } paths: # ------------------------------------------------------------------ Appointments /appointments: get: tags: [Appointments] summary: List appointments parameters: - $ref: '#/components/parameters/Page' - $ref: '#/components/parameters/Length' - $ref: '#/components/parameters/Sort' responses: '200': { $ref: '#/components/responses/Success' } '401': { $ref: '#/components/responses/Unauthorized' } '429': { $ref: '#/components/responses/RateLimited' } post: tags: [Appointments] summary: Create an appointment requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/AppointmentCreate' } responses: '201': { $ref: '#/components/responses/Success' } '401': { $ref: '#/components/responses/Unauthorized' } '422': { $ref: '#/components/responses/ValidationError' } /appointments/counts: get: tags: [Appointments] summary: Appointment counts by status responses: '200': { $ref: '#/components/responses/Success' } '401': { $ref: '#/components/responses/Unauthorized' } /appointments/summary: get: tags: [Appointments] summary: Aggregated appointment summary responses: '200': { $ref: '#/components/responses/Success' } '401': { $ref: '#/components/responses/Unauthorized' } /appointments/search: get: tags: [Appointments] summary: Search appointments parameters: - $ref: '#/components/parameters/Query' responses: '200': { $ref: '#/components/responses/Success' } '401': { $ref: '#/components/responses/Unauthorized' } /appointments/filters: get: tags: [Appointments] summary: Available filter options for appointment lists responses: '200': { $ref: '#/components/responses/Success' } '401': { $ref: '#/components/responses/Unauthorized' } /appointments/check-availability: post: tags: [Appointments] summary: Check whether a proposed slot is bookable requestBody: required: true content: application/json: schema: { type: object } responses: '200': { $ref: '#/components/responses/Success' } '401': { $ref: '#/components/responses/Unauthorized' } /appointments/{id}: parameters: - name: id in: path required: true schema: { type: integer } get: tags: [Appointments] summary: Get an appointment responses: '200': { $ref: '#/components/responses/Success' } '401': { $ref: '#/components/responses/Unauthorized' } '404': { $ref: '#/components/responses/NotFound' } patch: tags: [Appointments] summary: Update an appointment requestBody: required: true content: application/json: schema: { type: object } responses: '200': { $ref: '#/components/responses/Success' } '401': { $ref: '#/components/responses/Unauthorized' } '422': { $ref: '#/components/responses/ValidationError' } delete: tags: [Appointments] summary: Delete an appointment responses: '200': { $ref: '#/components/responses/Success' } '401': { $ref: '#/components/responses/Unauthorized' } '404': { $ref: '#/components/responses/NotFound' } /appointments/{id}/status: parameters: - name: id in: path required: true schema: { type: integer } patch: tags: [Appointments] summary: Update appointment status requestBody: required: true content: application/json: schema: type: object properties: status: { type: string, example: "cancelled" } responses: '200': { $ref: '#/components/responses/Success' } '401': { $ref: '#/components/responses/Unauthorized' } '422': { $ref: '#/components/responses/ValidationError' } /appointments/{id}/notes: parameters: - name: id in: path required: true schema: { type: integer } patch: tags: [Appointments] summary: Update appointment notes requestBody: required: true content: application/json: schema: type: object properties: notes: { type: string } responses: '200': { $ref: '#/components/responses/Success' } '401': { $ref: '#/components/responses/Unauthorized' } /appointments/{id}/payment-status: parameters: - name: id in: path required: true schema: { type: integer } patch: tags: [Appointments] summary: Update appointment payment status requestBody: required: true content: application/json: schema: type: object properties: payment_status: { type: string, example: "paid" } responses: '200': { $ref: '#/components/responses/Success' } '401': { $ref: '#/components/responses/Unauthorized' } /appointments/{id}/notify: parameters: - name: id in: path required: true schema: { type: integer } post: tags: [Appointments] summary: Queue a notification for an appointment responses: '200': { $ref: '#/components/responses/Success' } '401': { $ref: '#/components/responses/Unauthorized' } # ------------------------------------------------------------------ Availability /availability/slots: get: tags: [Availability] summary: List available time slots parameters: - { name: providerId, in: query, schema: { type: integer } } - { name: serviceId, in: query, schema: { type: integer } } - { name: date, in: query, schema: { type: string, format: date } } responses: '200': { $ref: '#/components/responses/Success' } /availability/check: post: tags: [Availability] summary: Check availability for a specific slot requestBody: required: true content: application/json: schema: { type: object } responses: '200': { $ref: '#/components/responses/Success' } /availability/summary: get: tags: [Availability] summary: Availability summary over a range responses: '200': { $ref: '#/components/responses/Success' } /availability/calendar: get: tags: [Availability] summary: Availability shaped for a calendar view responses: '200': { $ref: '#/components/responses/Success' } /availability/next-available: get: tags: [Availability] summary: Next available slot for a provider/service responses: '200': { $ref: '#/components/responses/Success' } # ------------------------------------------------------------------ Services /services: get: tags: [Services] summary: List services parameters: - $ref: '#/components/parameters/Page' - $ref: '#/components/parameters/Length' - $ref: '#/components/parameters/Sort' - { name: providerId, in: query, schema: { type: integer } } responses: '200': { $ref: '#/components/responses/Success' } '401': { $ref: '#/components/responses/Unauthorized' } post: tags: [Services] summary: Create a service (admin/provider) requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/ServiceWrite' } responses: '201': { $ref: '#/components/responses/Success' } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '422': { $ref: '#/components/responses/ValidationError' } /services/{id}: parameters: - name: id in: path required: true schema: { type: integer } get: tags: [Services] summary: Get a service responses: '200': { $ref: '#/components/responses/Success' } '404': { $ref: '#/components/responses/NotFound' } put: tags: [Services] summary: Update a service (admin/provider) requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/ServiceWrite' } responses: '200': { $ref: '#/components/responses/Success' } '403': { $ref: '#/components/responses/Forbidden' } '422': { $ref: '#/components/responses/ValidationError' } patch: tags: [Services] summary: Update a service (admin/provider) requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/ServiceWrite' } responses: '200': { $ref: '#/components/responses/Success' } '403': { $ref: '#/components/responses/Forbidden' } '422': { $ref: '#/components/responses/ValidationError' } delete: tags: [Services] summary: Delete a service (admin/provider) responses: '200': { $ref: '#/components/responses/Success' } '403': { $ref: '#/components/responses/Forbidden' } # ------------------------------------------------------------------ Categories /categories: get: tags: [Categories] summary: List categories parameters: - $ref: '#/components/parameters/Page' - $ref: '#/components/parameters/Length' - $ref: '#/components/parameters/Sort' responses: '200': { $ref: '#/components/responses/Success' } '401': { $ref: '#/components/responses/Unauthorized' } post: tags: [Categories] summary: Create a category (admin/provider) requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/CategoryWrite' } responses: '201': { $ref: '#/components/responses/Success' } '403': { $ref: '#/components/responses/Forbidden' } '422': { $ref: '#/components/responses/ValidationError' } /categories/{id}: parameters: - name: id in: path required: true schema: { type: integer } get: tags: [Categories] summary: Get a category responses: '200': { $ref: '#/components/responses/Success' } '404': { $ref: '#/components/responses/NotFound' } put: tags: [Categories] summary: Update a category (admin/provider) requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/CategoryWrite' } responses: '200': { $ref: '#/components/responses/Success' } '403': { $ref: '#/components/responses/Forbidden' } '422': { $ref: '#/components/responses/ValidationError' } patch: tags: [Categories] summary: Update a category (admin/provider) requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/CategoryWrite' } responses: '200': { $ref: '#/components/responses/Success' } '403': { $ref: '#/components/responses/Forbidden' } delete: tags: [Categories] summary: Delete a category (admin/provider) responses: '200': { $ref: '#/components/responses/Success' } '403': { $ref: '#/components/responses/Forbidden' } # ------------------------------------------------------------------ Providers /providers: get: tags: [Providers] summary: List providers (public) security: [] parameters: - $ref: '#/components/parameters/Page' - $ref: '#/components/parameters/Length' - { name: includeColors, in: query, schema: { type: boolean } } responses: '200': { $ref: '#/components/responses/Success' } post: tags: [Providers] summary: Create a provider (admin/provider) requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/ProviderWrite' } responses: '201': { $ref: '#/components/responses/Success' } '403': { $ref: '#/components/responses/Forbidden' } '422': { $ref: '#/components/responses/ValidationError' } /providers/{id}: parameters: - name: id in: path required: true schema: { type: integer } get: tags: [Providers] summary: Get a provider responses: '200': { $ref: '#/components/responses/Success' } '404': { $ref: '#/components/responses/NotFound' } put: tags: [Providers] summary: Update a provider (admin/provider) requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/ProviderWrite' } responses: '200': { $ref: '#/components/responses/Success' } '403': { $ref: '#/components/responses/Forbidden' } '422': { $ref: '#/components/responses/ValidationError' } patch: tags: [Providers] summary: Update a provider (admin/provider) requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/ProviderWrite' } responses: '200': { $ref: '#/components/responses/Success' } '403': { $ref: '#/components/responses/Forbidden' } delete: tags: [Providers] summary: Deactivate a provider (admin) responses: '200': { $ref: '#/components/responses/Success' } '403': { $ref: '#/components/responses/Forbidden' } /providers/{id}/services: parameters: - name: id in: path required: true schema: { type: integer } get: tags: [Providers] summary: List a provider's services (public) security: [] responses: '200': { $ref: '#/components/responses/Success' } /providers/{id}/appointments: parameters: - name: id in: path required: true schema: { type: integer } get: tags: [Providers] summary: List a provider's appointments (public monthly view) security: [] parameters: - { name: month, in: query, schema: { type: string, example: "2026-07" } } responses: '200': { $ref: '#/components/responses/Success' } /providers/{id}/schedule: parameters: - name: id in: path required: true schema: { type: integer } get: tags: [Providers] summary: Get a provider's weekly schedule (public) security: [] responses: '200': { $ref: '#/components/responses/Success' } /providers/{id}/profile-image: parameters: - name: id in: path required: true schema: { type: integer } post: tags: [Providers] summary: Upload a provider profile image (admin/provider) requestBody: required: true content: multipart/form-data: schema: type: object properties: image: { type: string, format: binary } responses: '200': { $ref: '#/components/responses/Success' } '400': { $ref: '#/components/responses/ValidationError' } '401': { $ref: '#/components/responses/Unauthorized' } '404': { $ref: '#/components/responses/NotFound' } /providers/slug/{slug}/services: parameters: - name: slug in: path required: true schema: { type: string } get: tags: [Providers] summary: List services for a provider resolved by public slug security: [] responses: '200': { $ref: '#/components/responses/Success' } '404': { $ref: '#/components/responses/NotFound' } # ------------------------------------------------------------------ Customers /customers: get: tags: [Customers] summary: List customers parameters: - $ref: '#/components/parameters/Page' - $ref: '#/components/parameters/Length' - $ref: '#/components/parameters/Sort' - $ref: '#/components/parameters/Query' responses: '200': { $ref: '#/components/responses/Success' } '401': { $ref: '#/components/responses/Unauthorized' } post: tags: [Customers] summary: Create a customer (admin/provider/staff) requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/CustomerWrite' } responses: '201': { $ref: '#/components/responses/Success' } '403': { $ref: '#/components/responses/Forbidden' } '422': { $ref: '#/components/responses/ValidationError' } /customers/{id}: parameters: - name: id in: path required: true schema: { type: integer } get: tags: [Customers] summary: Get a customer responses: '200': { $ref: '#/components/responses/Success' } '404': { $ref: '#/components/responses/NotFound' } put: tags: [Customers] summary: Update a customer (admin/provider/staff) requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/CustomerWrite' } responses: '200': { $ref: '#/components/responses/Success' } '403': { $ref: '#/components/responses/Forbidden' } '422': { $ref: '#/components/responses/ValidationError' } patch: tags: [Customers] summary: Update a customer (admin/provider/staff) requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/CustomerWrite' } responses: '200': { $ref: '#/components/responses/Success' } '403': { $ref: '#/components/responses/Forbidden' } delete: tags: [Customers] summary: Delete a customer (admin/provider/staff) description: > Fails with 422 (`appointments_exist`) when the customer has appointment history, which must be removed first. responses: '200': { $ref: '#/components/responses/Success' } '403': { $ref: '#/components/responses/Forbidden' } '422': { $ref: '#/components/responses/ValidationError' } /customers/{id}/appointments: parameters: - name: id in: path required: true schema: { type: integer } get: tags: [Customers] summary: List a customer's appointments responses: '200': { $ref: '#/components/responses/Success' } '401': { $ref: '#/components/responses/Unauthorized' } /customers/{id}/appointments/upcoming: parameters: - name: id in: path required: true schema: { type: integer } get: tags: [Customers] summary: List a customer's upcoming appointments responses: '200': { $ref: '#/components/responses/Success' } '401': { $ref: '#/components/responses/Unauthorized' } /customers/{id}/appointments/history: parameters: - name: id in: path required: true schema: { type: integer } get: tags: [Customers] summary: List a customer's past appointments responses: '200': { $ref: '#/components/responses/Success' } '401': { $ref: '#/components/responses/Unauthorized' } /customers/{id}/appointments/stats: parameters: - name: id in: path required: true schema: { type: integer } get: tags: [Customers] summary: Appointment statistics for a customer responses: '200': { $ref: '#/components/responses/Success' } '401': { $ref: '#/components/responses/Unauthorized' } /customers/{id}/autofill: parameters: - name: id in: path required: true schema: { type: integer } get: tags: [Customers] summary: Autofill payload for a known customer responses: '200': { $ref: '#/components/responses/Success' } '401': { $ref: '#/components/responses/Unauthorized' } # ------------------------------------------------------------------ Business Hours /business-hours: get: tags: [Business Hours] summary: Get per-day business hours responses: '200': { $ref: '#/components/responses/Success' } '401': { $ref: '#/components/responses/Unauthorized' } put: tags: [Business Hours] summary: Set the global work window (admin) requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/BusinessHoursWrite' } responses: '200': { $ref: '#/components/responses/Success' } '403': { $ref: '#/components/responses/Forbidden' } '422': { $ref: '#/components/responses/ValidationError' } post: tags: [Business Hours] summary: Set the global work window (admin; alias of PUT for WAF-restricted hosts) requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/BusinessHoursWrite' } responses: '200': { $ref: '#/components/responses/Success' } '403': { $ref: '#/components/responses/Forbidden' } '422': { $ref: '#/components/responses/ValidationError' } # ------------------------------------------------------------------ Locations /locations: get: tags: [Locations] summary: List locations (public) security: [] responses: '200': { $ref: '#/components/responses/Success' } post: tags: [Locations] summary: Create a location requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/Location' } responses: '201': { $ref: '#/components/responses/Success' } '401': { $ref: '#/components/responses/Unauthorized' } '422': { $ref: '#/components/responses/ValidationError' } /locations/{id}: parameters: - name: id in: path required: true schema: { type: integer } get: tags: [Locations] summary: Get a location (public) security: [] responses: '200': { $ref: '#/components/responses/Success' } '404': { $ref: '#/components/responses/NotFound' } put: tags: [Locations] summary: Update a location requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/Location' } responses: '200': { $ref: '#/components/responses/Success' } '401': { $ref: '#/components/responses/Unauthorized' } patch: tags: [Locations] summary: Update a location requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/Location' } responses: '200': { $ref: '#/components/responses/Success' } '401': { $ref: '#/components/responses/Unauthorized' } delete: tags: [Locations] summary: Delete a location responses: '200': { $ref: '#/components/responses/Success' } '401': { $ref: '#/components/responses/Unauthorized' } /locations/{id}/set-primary: parameters: - name: id in: path required: true schema: { type: integer } post: tags: [Locations] summary: Mark a location as primary responses: '200': { $ref: '#/components/responses/Success' } '401': { $ref: '#/components/responses/Unauthorized' }