openapi: 3.0.3 info: title: Radiant Wave Portal API (v2) version: 2.0.0 description: | Read API used by the Radiant Wave kiosk plus the automation onboarding webhook. ## Authentication The v2 API is **not** API-key gated. The former bearer-token middleware in `src/hooks.server.ts` is present but disabled, so v2 routes are served without a global API key. Two endpoints carry their own credential semantics: - `GET /api/v2/proclamations` optionally accepts the paired `x-license-key` / `x-email` headers to scope results to an entitled user. Supplying neither returns the full catalogue; supplying only one, or a pair that does not match, is rejected. - `POST /api/v2/automations/create/account` is protected by an HMAC-SHA256 request signature (see the operation description). servers: - url: https://portal.radiantwavetech.com description: Production - url: http://localhost:5173 description: Local development tags: - name: Catalogue description: Reference data for categories, groups, frequencies and palettes. - name: Proclamations description: Proclamation content, optionally scoped by license entitlement. - name: License description: License key validation. - name: Schedule description: A user's daily listening schedule, consumed by the kiosk. - name: Automations description: Signed machine-to-machine webhooks. paths: /api/v2/categories: get: tags: [Catalogue] summary: List proclamation categories operationId: listCategories responses: '200': description: All proclamation categories. content: application/json: schema: type: array items: $ref: '#/components/schemas/Category' /api/v2/proclamation-categories: get: tags: [Catalogue] summary: List proclamation categories (dedicated alias) description: Alias of `/api/v2/categories`; returns the identical payload. operationId: listProclamationCategories responses: '200': description: All proclamation categories. content: application/json: schema: type: array items: $ref: '#/components/schemas/Category' /api/v2/groups: get: tags: [Catalogue] summary: List proclamation groups description: Groups are ordered by `sortOrder` ascending. operationId: listGroups responses: '200': description: All proclamation groups. content: application/json: schema: type: array items: $ref: '#/components/schemas/Group' /api/v2/proclamation-groups: get: tags: [Catalogue] summary: List proclamation groups (dedicated alias) description: Alias of `/api/v2/groups`; returns the identical payload. operationId: listProclamationGroups responses: '200': description: All proclamation groups. content: application/json: schema: type: array items: $ref: '#/components/schemas/Group' /api/v2/frequencies: get: tags: [Catalogue] summary: List canonical solfeggio frequencies description: | Returns the nine canonical frequencies (ids 1-9) the kiosk expects. These are static values, not the portal database autoincrement ids. operationId: listFrequencies responses: '200': description: The nine canonical frequencies. content: application/json: schema: type: array minItems: 9 maxItems: 9 items: $ref: '#/components/schemas/Frequency' /api/v2/color-palettes: get: tags: [Catalogue] summary: List color palettes per canonical frequency description: | Returns one 7-color palette for each canonical frequency id (1-9). Palette rows stored in the database take precedence; ids without a row fall back to the built-in canonical palette. operationId: listColorPalettes responses: '200': description: Nine palettes, one per canonical frequency id. content: application/json: schema: type: array minItems: 9 maxItems: 9 items: $ref: '#/components/schemas/ColorPalette' /api/v2/proclamations: get: tags: [Proclamations] summary: List proclamations description: | Without entitlement headers, returns the full proclamation catalogue. With both `x-license-key` and `x-email`, returns only the proclamations assigned to the matching user. Both headers must be present together and the key must belong to the user. Proclamations whose frequency has no canonical id (for example 936 Hz) are omitted from the response. operationId: listProclamations parameters: - in: header name: x-license-key schema: type: string required: false description: License key. Must be supplied together with `x-email`. - in: header name: x-email schema: type: string format: email required: false description: Account email. Must be supplied together with `x-license-key`. responses: '200': description: Proclamation records with canonical frequency ids and embedded palettes. content: application/json: schema: type: array items: $ref: '#/components/schemas/Proclamation' '400': description: | Missing entitlement or the supplied key/email pair is invalid. Message is `not-authorized`, or `this key doesn't appear to belong to you` when the key does not belong to the specified user. content: application/json: schema: $ref: '#/components/schemas/ErrorMessage' /api/v2/license: post: tags: [License] summary: Validate a license key description: | Validates that the license key exists, is active, and belongs to the account matching `email_address`. A successful validation records `lastValidatedAt` on the key. operationId: validateLicense requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/LicenseRequest' responses: '200': description: The validated key and its owner email. content: application/json: schema: $ref: '#/components/schemas/LicenseResponse' '400': description: | Validation failure or malformed body. Possible messages: `content-type must be application/json`, `invalid JSON body`, `request body is required`, `license_key is required`, `email_address is required`, `license key not found`, `license key is not active`, `user does not exist`, `key does not belong to specified user`. content: application/json: schema: $ref: '#/components/schemas/ErrorMessage' '413': description: The request body exceeded the 64 KiB limit. content: application/json: schema: $ref: '#/components/schemas/ErrorMessage' '429': description: Rate limit exceeded (10 requests per minute per IP). headers: Retry-After: schema: type: integer description: Seconds to wait before retrying. content: application/json: schema: $ref: '#/components/schemas/ErrorMessage' /api/v2/schedule: get: tags: [Schedule] summary: Get a user's listening schedule (query-param auth) description: | Machine-to-app endpoint used by the RadiantWave kiosk. Authenticates via the paired `email` + `licenseKey` **query parameters** and validates only that the key belongs to the account for that email. License active/entitlement is deliberately not checked here: an invalid license would already lock the kiosk out, so this endpoint only checks the pairing. Returns every Block with its start/end times and the ordered list of selected Proclamation IDs (the legacy daily flattening in `blocks`), and is extended additively with `version`, `type`, `daily`, and `weekly`. Overnight blocks (an end clock time before the start) are supported and flagged with `overnight: true` and a wrapping `durationMinutes`. Each block also carries `dynamicAudio`; when true, the kiosk enables dynamic audio for the block's duration, overriding any audio selections made on the device. Proclamation content is not included; the kiosk fetches it separately via `/api/v2/proclamations`. For `type: "weekly"`, `blocks` is an empty array (the server cannot flatten weekly in the kiosk's timezone). This endpoint emits no ETag and never returns 304. operationId: getSchedule parameters: - in: query name: email required: true schema: type: string format: email description: Account email. - in: query name: licenseKey required: true schema: type: string pattern: '^[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$' description: License key. The legacy alias `key` is also accepted. responses: '200': description: The user's schedule (possibly empty). content: application/json: schema: $ref: '#/components/schemas/ScheduleResponse' example: email: jane@example.com blocks: - id: 3f1c2b7a-9d4e-4c8a-b2f6-1a2b3c4d5e6f startTime: '21:00' startMinutes: 1260 endTime: '09:00' endMinutes: 540 overnight: true durationMinutes: 720 dynamicAudio: true proclamationIds: - b40fdcb9-703c-420e-af32-a081964c6f08 - 7c9e1a2b-3d4e-5f60-8a9b-0c1d2e3f4a5b '400': description: | Missing/invalid parameters, unknown key or user, or the key does not belong to the email's account. Possible messages: `not-authorized`, `this key doesn't appear to belong to you`. content: application/json: schema: $ref: '#/components/schemas/ErrorMessage' '429': description: Rate limit exceeded (10 requests per minute per IP). headers: Retry-After: schema: type: integer description: Seconds to wait before retrying. content: application/json: schema: $ref: '#/components/schemas/ErrorMessage' /api/v2/licenses/{licenseKey}/schedule: get: tags: [Schedule] summary: Get a License Key's schedule (Kiosk-facing, conditional) description: | Kiosk-facing, License-Key-only schedule read. Returns the target payload (`version`, `type`, `daily`, `weekly`, `blocks`). `version` is a content-derived opaque string and is also the `ETag`; a matching `If-None-Match` returns `304`. An unknown License Key is `404`; a key with no Schedule returns `type: null` with empty lists. No authentication (see the API description) beyond the 10/min/IP limit. operationId: getLicenseSchedule parameters: - in: path name: licenseKey required: true schema: type: string pattern: '^[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$' - in: header name: If-None-Match required: false schema: type: string description: Prior ETag; when it matches, returns 304. responses: '200': description: The target schedule payload. headers: ETag: schema: type: string description: The quoted `version`. content: application/json: schema: $ref: '#/components/schemas/SchedulePayload' '304': description: Not modified; the caller keeps its cached payload. headers: ETag: schema: type: string '404': description: 'Unknown license (`unknown_license`).' content: application/json: schema: $ref: '#/components/schemas/ErrorMessage' '429': description: Rate limit exceeded (10 requests per minute per IP). headers: Retry-After: schema: type: integer description: Seconds to wait before retrying. content: application/json: schema: $ref: '#/components/schemas/ErrorMessage' /api/v2/licenses/{licenseKey}/system-name: put: tags: [License] summary: Set a License Key's System Name (Kiosk-only) description: | Sets the human-readable System Name for a License Key. The Kiosk is the only writer; the Portal displays the name read-only. The name is unique per account, compared case-insensitively, when non-null. Idempotent: re-sending the current name returns 200 without a write. operationId: setSystemName parameters: - in: path name: licenseKey required: true schema: type: string pattern: '^[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$' requestBody: required: true content: application/json: schema: type: object required: [systemName] properties: systemName: type: string minLength: 1 maxLength: 40 responses: '200': description: The stored name. content: application/json: schema: type: object required: [systemName, systemNameStatus] properties: systemName: type: string systemNameStatus: type: string enum: [ok] '400': description: 'Invalid name (`invalid_name`); empty/whitespace is invalid.' content: application/json: schema: $ref: '#/components/schemas/ErrorMessage' '404': description: 'Unknown license (`unknown_license`).' content: application/json: schema: $ref: '#/components/schemas/ErrorMessage' '409': description: 'Name already used on the account (`name_taken`).' content: application/json: schema: $ref: '#/components/schemas/ErrorMessage' '429': description: Rate limit exceeded (10 requests per minute per IP). headers: Retry-After: schema: type: integer description: Seconds to wait before retrying. content: application/json: schema: $ref: '#/components/schemas/ErrorMessage' /api/v2/automations/create/account: post: tags: [Automations] summary: Create an account and license key (signed webhook) description: | Machine-to-machine onboarding webhook. The request must be signed with HMAC-SHA256 using `API_V2_ONBOARDING_SECRET`. The canonical string that is signed is the newline-joined concatenation of: ``` POST ``` where `` is `/api/v2/automations/create/account`. The signature is the lowercase hex digest. On success, creates the user if they do not already exist, generates a license key, emails it, and returns the user id. Invalid or missing signatures return `401 { "error": "invalid signature" }` (the same response is used for every signature failure, including replay and timestamp skew). operationId: createAutomationAccount parameters: - in: header name: x-timestamp required: true schema: type: string description: Unix epoch seconds. Must be within 300 seconds of the server clock. - in: header name: x-nonce required: true schema: type: string description: Unique value per request. Replays within the skew window are rejected. - in: header name: x-signature required: true schema: type: string pattern: '^[0-9a-f]{64}$' description: Lowercase hex HMAC-SHA256 of the canonical request string. requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/OnboardingRequest' responses: '200': description: Account and license key created. content: application/json: schema: $ref: '#/components/schemas/OnboardingResponse' '400': description: | Malformed or invalid request. Possible messages: `request body is required`, `invalid JSON body`, or `email is required`. content: application/json: schema: $ref: '#/components/schemas/ErrorMessage' '401': description: Signature missing, malformed, expired, replayed, or incorrect. content: application/json: schema: type: object required: [error] properties: error: type: string example: invalid signature '413': description: The request body exceeded the 64 KiB limit. content: application/json: schema: $ref: '#/components/schemas/ErrorMessage' '429': description: | Rate limit exceeded. Limits are 5 requests per minute per IP and 3 requests per 24 hours per email address. headers: Retry-After: schema: type: integer description: Seconds to wait before retrying. content: application/json: schema: $ref: '#/components/schemas/ErrorMessage' '500': description: The account could not be created. content: application/json: schema: $ref: '#/components/schemas/ErrorMessage' components: schemas: Category: type: object required: [id, name, sortOrder] properties: id: type: integer name: type: string sortOrder: type: integer Group: type: object required: [id, name, sortOrder] properties: id: type: integer name: type: string sortOrder: type: integer Frequency: type: object required: [id, name, value] properties: id: type: integer minimum: 1 maximum: 9 description: Canonical id used by the kiosk. name: type: string value: type: integer description: Frequency in Hz. ColorPalette: type: object required: [solfeggioFrequencyId, colors] properties: solfeggioFrequencyId: type: integer minimum: 1 maximum: 9 colors: type: array description: Seven hex colors, ordered light to dark. minItems: 1 items: type: string pattern: '^#[0-9A-Fa-f]{6}$' Proclamation: type: object required: [id, name, content, categoryId, groupId, solfeggioFrequencyId] properties: id: type: string format: uuid name: type: string content: type: string description: HTML content. categoryId: type: integer groupId: type: integer solfeggioFrequencyId: type: integer minimum: 1 maximum: 9 colorPalette: type: array description: Present only when a palette exists for the canonical frequency id. minItems: 1 items: type: string pattern: '^#[0-9A-Fa-f]{6}$' LicenseRequest: type: object required: [license_key, email_address] properties: license_key: type: string pattern: '^[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$' example: ABCD-EFGH-IJKL-MNOP email_address: type: string format: email maxLength: 254 systemName: type: string minLength: 1 maxLength: 40 description: | Optional. The Kiosk's System Name (trimmed). Omitted means the stored name is left untouched; empty/whitespace is invalid, never a clear. LicenseResponse: type: object required: [key, email_address, systemNameStatus] properties: key: type: string example: ABCD-EFGH-IJKL-MNOP email_address: type: string format: email systemNameStatus: type: string enum: [ok, unchanged, conflict, invalid] description: | Advisory outcome of the optional `systemName`. Validation itself always succeeds. `unchanged` is returned when the field is omitted. ScheduleBlock: type: object required: [ id, startTime, startMinutes, endTime, endMinutes, overnight, durationMinutes, dynamicAudio, proclamationIds ] properties: id: type: string format: uuid startTime: type: string example: '06:00' description: Wall-clock start time as `HH:MM`. startMinutes: type: integer minimum: 0 maximum: 1440 description: Minutes from midnight. endTime: type: string example: '08:00' description: Wall-clock end time as `HH:MM`. endMinutes: type: integer minimum: 0 maximum: 1440 description: Minutes from midnight. overnight: type: boolean description: True when the end clock time is at or before the start, i.e. the block crosses midnight. durationMinutes: type: integer minimum: 1 description: Block length in minutes. An overnight block counts the wrap past midnight as the next day. dynamicAudio: type: boolean description: | When true, dynamic audio is enabled for the block's duration, overriding any audio selections made on the device while the block is active. proclamationIds: type: array description: IDs of the selected Proclamations, in play order. items: type: string format: uuid ScheduleResponse: type: object required: [email, blocks] properties: email: type: string format: email version: type: string type: type: string nullable: true enum: [daily, weekly, null] daily: type: array items: $ref: '#/components/schemas/ScheduleBlock' weekly: type: object additionalProperties: type: array items: $ref: '#/components/schemas/ScheduleBlock' blocks: type: array items: $ref: '#/components/schemas/ScheduleBlock' SchedulePayload: type: object required: [version, type, daily, weekly, blocks] properties: version: type: string description: Opaque content hash; doubles as the ETag. type: type: string nullable: true enum: [daily, weekly, null] daily: type: array items: $ref: '#/components/schemas/ScheduleBlock' weekly: type: object description: Present with all seven keys when `type` is weekly. additionalProperties: type: array items: $ref: '#/components/schemas/ScheduleBlock' blocks: type: array description: Legacy daily flattening; empty for weekly. items: $ref: '#/components/schemas/ScheduleBlock' OnboardingRequest: type: object required: [email] properties: email: type: string format: email maxLength: 254 name: type: string maxLength: 120 description: Optional display name. Defaults to the email local-part. OnboardingResponse: type: object required: [success, message, user_id] properties: success: type: boolean message: type: string example: Account and license key created user_id: type: string ErrorMessage: type: object required: [message] properties: message: type: string