openapi: 3.1.0

info:
  title: LaunchSite OS Partner API
  version: "1.2.0"
  summary: Create and manage clients from your own systems.
  description: |
    The Partner API lets a coach's own stack - an EMR, GoHighLevel, Zapier, Make, n8n, or a script -
    create and manage clients without a human in the LaunchSite OS UI.

    **The arrow points one way: your platform calls us.** We hold no credential for your systems and
    run no OAuth against them. You mint a key here and paste it there.

    ## Authentication

    Client endpoints need a `partner`-scoped API key, created under
    **Settings → Private Integrations**. Send it either way:

    ```
    Authorization: Bearer lsk_live_...
    X-Api-Key: lsk_live_...
    ```

    The separate `/v1/crm/*` endpoints require a `crm` key (`crm_live_...`) and the selected
    permission named on each operation. Existing partner keys cannot access those endpoints.

    A key is shown once, at creation. Only a hash is stored - if you lose it, revoke and mint a new
    one. Revoking never deletes the record, because it is the audit trail.

    ## Tenancy

    The coach is resolved **from the key and nowhere else**. No endpoint accepts a `coach_id`,
    `owner_id` or `team_id` in a payload: a platform posting to us proves which coach it is and has
    no authority to name a different one.

    Reads filter on ownership, so a client handed to a teammate is not your key's to read.

    ## Rate limits

    | Scope | Limit |
    | --- | --- |
    | All endpoints | 300 requests per minute, per key |
    | `POST /v1/clients/{clientId}/login-link` | 20 per minute, per key |

    Exceeding either returns `429` with a `Retry-After` header. The bucket is the **key**, not your
    IP - automation platforms call from shared egress pools.

    ## Retries and idempotency

    Automation platforms retry: on timeouts, on 5xx, and on a success whose response they failed to
    read. `POST /v1/clients` creates an auth user and emails a person, so a blind replay would mean
    a second account and a second credential email.

    Send `Idempotency-Key: <your contact id>`. If you omit it we derive one from the email, because a
    silent duplicate is worse than an opinionated default - but an explicit key is what you should
    send, since it survives a corrected typo in the address.

    | Situation | Response |
    | --- | --- |
    | Nobody has started it | Proceeds normally |
    | Someone finished it | The stored response, with `Idempotent-Replay: true` |
    | Someone is running it now | `409`, with `Retry-After: 5` |
    | The work returned 4xx | Recorded - a deterministic verdict is the right answer to give again |
    | The work returned 5xx | Claim dropped, so your retry runs properly |

    ## Validation

    Fields are **rejected, not truncated**. A value longer than its documented `maxLength` returns
    `400` naming the field and the limit, rather than silently storing a shortened version. A field
    of the wrong type is also a `400`.

    ## What is deliberately not here

    - **Check-in content.** `GET /v1/clients/{clientId}` returns the latest check-in *date*, not its
      answers. Adding it later is additive; removing it after an integration reads it is not.
    - **Anything that moves a client between coaches, changes phase, or touches billing.** Those are
      decided server-side or by the coach in the app.
    - **Check-in answers, labs, progress photos and message bodies over events.** Excluded from
      every event payload in both modes, with no setting that reaches them. An event tells you
      something happened and hands you an id; the scoped REST read above is how you fetch it.

    ## Events, the other direction

    Everything above is your system calling us. The `webhooks` section of this document is us
    calling you: register a URL under **Settings → Private Integrations**, and we POST to it.

    Two ways to reach an event, and which one you use is not a preference:

    - **Emitted** events happen at a definite instant - a check-in was submitted, a client was
      created. Tick them on the endpoint and they arrive. No automation involved.
    - **Computed** events have no instant of their own. *Compliance dropped* is a judgement
      against a threshold the coach chose, so there is nothing to subscribe to. You reach these
      by adding a Webhook step to the automation that sets the threshold - which is why those
      payloads carry `automation`.

    Deliveries are signed with [Standard Webhooks](https://www.standardwebhooks.com/) v1.0.0 and
    retried 8 times over roughly a day. Verify before you act: your URL is public, and a receiver
    that trusts unverified input will do whatever a stranger asks it to.

  contact:
    name: LaunchSite OS Support
    email: support@launchsite-os.com

servers:
  - url: https://launchsite-os.com/api
    description: Production. A coach on a custom domain may also use their own host.

security:
  - bearerAuth: []
  - apiKeyAuth: []

tags:
  - name: Diagnostics
    description: Confirm a key is wired up correctly, without side effects.
  - name: Clients
    description: Create, list, read and update the clients a key's coach owns.

paths:
  /v1/crm/contacts:
    get:
      tags: [ CRM ]
      operationId: listCrmContacts
      summary: List owned CRM contacts
      description: |
        Requires a separate crm_live_ key with contacts.read. Records are ordered by UUID.
        Follow next_cursor until null. Pages are live reads, not a transaction-wide export snapshot.
        Merged-away contacts are excluded. An identity record is separate from its opportunities and coaching client account.
      security: [ { crmBearerAuth: [] }, { crmApiKeyAuth: [] } ]
      parameters:
        - { $ref: '#/components/parameters/CrmCursor' }
        - { $ref: '#/components/parameters/CrmLimit' }
      responses:
        '200':
          description: Owned records and the next cursor.
          content:
            application/json:
              schema:
                type: object
                required: [ data, next_cursor ]
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/CrmContactsRecord'
                  next_cursor: { type: [ string, 'null' ], format: uuid }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: "Missing selected permission, ProCRM entitlement or enabled gym capability." }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/ServerError' }
    post:
      tags:
        - CRM
      operationId: createCrmContact
      summary: Create or match a CRM contact
      description: Requires a separate crm_live_ key with contacts.write. Creates an identity record without
        outreach, workflow enrollment or a coaching account. Existing contact preferences are preserved.
      security:
        - crmBearerAuth: []
        - crmApiKeyAuth: []
      parameters:
        - $ref: "#/components/parameters/CrmIdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                name:
                  type: string
                  minLength: 1
                  maxLength: 200
                email:
                  type: string
                  format: email
                  maxLength: 254
              required:
                - name
                - email
      responses:
        "400":
          description: Invalid fields or missing Idempotency-Key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CrmWriteError"
        "401":
          description: Missing, invalid or revoked CRM key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CrmWriteError"
        "403":
          description: Missing scope, owner configuration, Pro CRM access or gym capability.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CrmWriteError"
        "404":
          description: The record does not belong to this workspace.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CrmWriteError"
        "409":
          description: Conflict, occupied slot, changed idempotent payload or required checkout. checkout_required
            includes bookingUrl.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CrmWriteError"
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          description: Service could not confirm the operation. Retry with the same Idempotency-Key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CrmWriteError"
        "201":
          description: Persisted or provider-confirmed outcome.
          content:
            application/json:
              schema:
                type: object
                additionalProperties: false
                properties:
                  contact:
                    $ref: "#/components/schemas/CrmContactsRecord"
                required:
                  - contact

  /v1/crm/opportunities:
    get:
      tags: [ CRM ]
      operationId: listCrmOpportunities
      summary: List owned CRM opportunities
      description: |
        Requires a separate crm_live_ key with opportunities.read. Records are ordered by UUID.
        Follow next_cursor until null. Pages are live reads, not a transaction-wide export snapshot.
        Ownership follows the key workspace and never operational assignment.
      security: [ { crmBearerAuth: [] }, { crmApiKeyAuth: [] } ]
      parameters:
        - { $ref: '#/components/parameters/CrmCursor' }
        - { $ref: '#/components/parameters/CrmLimit' }
      responses:
        '200':
          description: Owned records and the next cursor.
          content:
            application/json:
              schema:
                type: object
                required: [ data, next_cursor ]
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/CrmOpportunitiesRecord'
                  next_cursor: { type: [ string, 'null' ], format: uuid }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: "Missing selected permission, ProCRM entitlement or enabled gym capability." }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/ServerError' }
    post:
      tags:
        - CRM
      operationId: createCrmOpportunity
      summary: Create an opportunity in the configured pipeline
      description: Requires a separate crm_live_ key with opportunities.write. The owner configures this key’s
        pipeline and initial open stage in the app. The request cannot choose ownership, pipeline, stage,
        labels or agent settings.
      security:
        - crmBearerAuth: []
        - crmApiKeyAuth: []
      parameters:
        - $ref: "#/components/parameters/CrmIdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                contactId:
                  type: string
                  format: uuid
                name:
                  type: string
                  minLength: 1
                  maxLength: 200
                valueCents:
                  type: integer
                  minimum: 0
                  maximum: 1000000000
              required:
                - contactId
      responses:
        "400":
          description: Invalid fields or missing Idempotency-Key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CrmWriteError"
        "401":
          description: Missing, invalid or revoked CRM key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CrmWriteError"
        "403":
          description: Missing scope, owner configuration, Pro CRM access or gym capability.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CrmWriteError"
        "404":
          description: The record does not belong to this workspace.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CrmWriteError"
        "409":
          description: Conflict, occupied slot, changed idempotent payload or required checkout. checkout_required
            includes bookingUrl.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CrmWriteError"
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          description: Service could not confirm the operation. Retry with the same Idempotency-Key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CrmWriteError"
        "201":
          description: Persisted or provider-confirmed outcome.
          content:
            application/json:
              schema:
                type: object
                additionalProperties: false
                properties:
                  opportunity:
                    $ref: "#/components/schemas/CrmOpportunitiesRecord"
                required:
                  - opportunity

  /v1/crm/calendars:
    get:
      tags: [ CRM ]
      operationId: listCrmCalendars
      summary: List owned CRM calendars
      description: |
        Requires a separate crm_live_ key with bookings.read. Records are ordered by UUID.
        Follow next_cursor until null. Pages are live reads, not a transaction-wide export snapshot.
        Ownership follows the key workspace and never operational assignment.
      security: [ { crmBearerAuth: [] }, { crmApiKeyAuth: [] } ]
      parameters:
        - { $ref: '#/components/parameters/CrmCursor' }
        - { $ref: '#/components/parameters/CrmLimit' }
      responses:
        '200':
          description: Owned records and the next cursor.
          content:
            application/json:
              schema:
                type: object
                required: [ data, next_cursor ]
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/CrmCalendarsRecord'
                  next_cursor: { type: [ string, 'null' ], format: uuid }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: "Missing selected permission, ProCRM entitlement or enabled gym capability." }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/ServerError' }

  /v1/crm/bookings:
    get:
      tags: [ CRM ]
      operationId: listCrmBookings
      summary: List owned CRM bookings
      description: |
        Requires a separate crm_live_ key with bookings.read. Records are ordered by UUID.
        Follow next_cursor until null. Pages are live reads, not a transaction-wide export snapshot.
        Returns current CRM calendar events; private general calendar entries without a CRM calendar are excluded.
      security: [ { crmBearerAuth: [] }, { crmApiKeyAuth: [] } ]
      parameters:
        - { $ref: '#/components/parameters/CrmCursor' }
        - { $ref: '#/components/parameters/CrmLimit' }
      responses:
        '200':
          description: Owned records and the next cursor.
          content:
            application/json:
              schema:
                type: object
                required: [ data, next_cursor ]
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/CrmBookingsRecord'
                  next_cursor: { type: [ string, 'null' ], format: uuid }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: "Missing selected permission, ProCRM entitlement or enabled gym capability." }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/ServerError' }
    post:
      tags:
        - CRM
      operationId: createCrmBooking
      summary: Book an available CRM appointment
      description: Requires a separate crm_live_ key with bookings.write. Uses the current owned calendar’s timezone
        and availability. The reservation is conflict checked. Paid calendars return checkout_required with
        the public booking URL; this operation never charges.
      security:
        - crmBearerAuth: []
        - crmApiKeyAuth: []
      parameters:
        - $ref: "#/components/parameters/CrmIdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                leadId: &a1
                  type: string
                  format: uuid
                calendarId: *a1
                date:
                  type: string
                  format: date
                  pattern: ^\d{4}-\d{2}-\d{2}$
                time:
                  type: string
                  pattern: ^([01]\d|2[0-3]):[0-5]\d$
              required:
                - leadId
                - calendarId
                - date
                - time
      responses:
        "400":
          description: Invalid fields or missing Idempotency-Key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CrmWriteError"
        "401":
          description: Missing, invalid or revoked CRM key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CrmWriteError"
        "403":
          description: Missing scope, owner configuration, Pro CRM access or gym capability.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CrmWriteError"
        "404":
          description: The record does not belong to this workspace.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CrmWriteError"
        "409":
          description: Conflict, occupied slot, changed idempotent payload or required checkout. checkout_required
            includes bookingUrl.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CrmWriteError"
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          description: Service could not confirm the operation. Retry with the same Idempotency-Key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CrmWriteError"
        "201":
          description: Persisted or provider-confirmed outcome.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CrmBookingOutcome"

  /v1/crm/workflows:
    get:
      tags: [ CRM ]
      operationId: listCrmWorkflows
      summary: List owned CRM workflows
      description: |
        Requires a separate crm_live_ key with workflows.read. Records are ordered by UUID.
        Follow next_cursor until null. Pages are live reads, not a transaction-wide export snapshot.
        Returns trigger and activation metadata; outbound credentials and step payloads are excluded.
      security: [ { crmBearerAuth: [] }, { crmApiKeyAuth: [] } ]
      parameters:
        - { $ref: '#/components/parameters/CrmCursor' }
        - { $ref: '#/components/parameters/CrmLimit' }
      responses:
        '200':
          description: Owned records and the next cursor.
          content:
            application/json:
              schema:
                type: object
                required: [ data, next_cursor ]
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/CrmWorkflowsRecord'
                  next_cursor: { type: [ string, 'null' ], format: uuid }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: "Missing selected permission, ProCRM entitlement or enabled gym capability." }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/ServerError' }
  /v1/ping:
    get:
      tags: [ Diagnostics ]
      operationId: ping
      summary: Check an API key
      description: |
        Side-effect-free. Returns the coach the key resolved to, so a human can confirm they pasted
        the **right** key - a working key for the wrong workspace is a failure no status code can
        express.

        This exists so that testing a pasted key does not mean creating a real patient and emailing
        a real person. Use it as the first step of any integration build.
      responses:
        '200':
          description: The key is valid and the workspace is entitled.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PingResult' }
              example:
                ok: true
                coach: { id: "0e8f...", name: "Dr. Smith", email: "coach@example.com" }
                active_clients: 42
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /v1/clients:
    get:
      tags: [ Clients ]
      operationId: listClients
      summary: List clients
      description: |
        Newest first. To answer *"do I already have this patient?"* before creating one, pass
        `email` - the filter runs in the query, so it is exact and independent of `limit`. Filtering
        a returned page yourself would answer "no" for a client who exists just beyond it.
      parameters:
        - name: limit
          in: query
          description: |
            Maximum rows to return. Never rejected: a value above 200 is capped at 200, and anything
            that is not a positive integer - `0`, a negative, or text - falls back to **50** rather
            than to the minimum.
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
        - name: active
          in: query
          description: |
            Pass exactly `true` to return only active clients. Any other value, including `false`,
            is ignored and returns both active and inactive - to list only inactive clients, fetch
            both and filter on `is_active`.
          schema: { type: string, enum: [ 'true' ] }
        - name: email
          in: query
          description: Exact match on the client's login email, case-insensitive.
          schema: { type: string, format: email, maxLength: 160 }
          example: patient@example.com
      responses:
        '200':
          description: The matching clients.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ClientList' }
              example:
                count: 2
                clients:
                  - client_id: "5f1c..."
                    full_name: "Jane Smith"
                    email: "jane@example.com"
                    is_active: true
                    current_phase: "build"
                    created_at: "2026-07-02T14:11:03.221Z"
                  - client_id: "9a20..."
                    full_name: "Ravi Patel"
                    email: "ravi@example.com"
                    is_active: false
                    current_phase: null
                    created_at: "2026-06-19T09:02:44.010Z"
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }

    post:
      tags: [ Clients ]
      operationId: createClient
      summary: Create a client
      description: |
        Creates the account, applies your plan and team caps, fires the `new_client` automation
        trigger, and reconciles against an existing CRM lead by email or phone.

        ## Delivery is your choice, and exactly one intake is minted either way

        - **`send_welcome: true`** (default) - we email credentials and the intake link, identical
          to the in-app path. `intake_url` comes back `null`, because we sent it.
        - **`send_welcome: false`** - you take delivery. `temp_password` and `intake_url` come back
          so your own branded message can carry both. The intake is recorded as *issued*, not
          *sent*, so the coach's Intakes list does not claim "Awaiting response" about someone we
          never contacted.

        `temp_password` is **also** returned when `send_welcome` was true but our send failed, so an
        account never exists with nobody holding a way to reach it.

        Send an `Idempotency-Key` header. See **Retries and idempotency** above.
      parameters:
        - name: Idempotency-Key
          in: header
          required: false
          description: |
            Your own stable identifier for this creation - a CRM contact id works well. Omitted, one
            is derived from the email address.
          schema: { type: string, maxLength: 255 }
          example: crm_contact_88213
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateClientRequest' }
            examples:
              weSendTheWelcome:
                summary: We email the client their credentials
                value:
                  full_name: Jane Smith
                  email: jane@example.com
                  phone: "+15551234567"
                  goals: Lower fasting glucose and sleep through the night.
              youSendTheWelcome:
                summary: You deliver credentials in your own branding
                value:
                  full_name: Jane Smith
                  email: jane@example.com
                  send_welcome: false
      responses:
        '201':
          description: The client was created.
          headers:
            Idempotent-Replay:
              description: |
                Present and `true` when this is a replay of an earlier identical request. It rides
                on **whatever status the original produced**, so a replayed `400` or `409` carries
                it too - treat it as "you have seen this answer before", not as "it succeeded".
              schema: { type: string, enum: [ 'true' ] }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CreateClientResult' }
              example:
                client_id: "5f1c..."
                email: jane@example.com
                full_name: Jane Smith
                welcome_email_sent: true
                intake_url: null
        '400':
          description: |
            The body was not a JSON object, a required field was missing, or a field failed
            validation - wrong type, or longer than its documented maximum.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                missingRequired:
                  value: { error: "full_name and email are required." }
                tooLong:
                  value: { error: "goals must be 2000 characters or fewer (received 3140)." }
                wrongType:
                  value: { error: "height_in must be a number." }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '409':
          description: |
            Four distinct causes share this status. Only the first is retryable as-is:

            - **A request with this `Idempotency-Key` is still in flight.** Carries `Retry-After`.
            - **An account already exists for that email address.** The most common cause in
              practice - check with `GET /v1/clients?email=` before creating.
            - **The plan's client limit would be exceeded.** The limit counts every client
              record stored for the coach, including deactivated ones, so a coach can hit it
              with an entirely archived roster.
            - **The team's client limit would be exceeded.**

            Branch on the message only for display; branch on the status for control flow.
          headers:
            Retry-After:
              description: Present only for an in-flight collision. Seconds to wait before retrying.
              schema: { type: integer }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                inFlight:
                  summary: A retry arrived while the first request was still running
                  value:
                    error: A request with this Idempotency-Key is already being processed. Retry shortly.
                duplicateEmail:
                  summary: That person already has an account with this coach
                  value:
                    error: An account already exists for that email address.
                planLimit:
                  summary: The plan's client cap
                  value:
                    error: >-
                      You've reached your plan's limit of 50 clients. Archived clients still count, because
                      their data is still stored - upgrade, or permanently delete a client to free a space.
        '429': { $ref: '#/components/responses/RateLimited' }
        '500':
          description: |
            Our failure. The idempotency claim is dropped, so retrying with the **same**
            `Idempotency-Key` runs properly rather than replaying the error.

            Two shapes occur. An unhandled fault carries the retry instruction and an `event_id` you
            can quote to support. A failure inside account creation surfaces the underlying provider
            message instead, with no `event_id` - so **do not parse `error`**; it is not a stable
            identifier.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                unhandled:
                  summary: Retryable, with a support reference
                  value:
                    error: Could not create the client. Retry with the same Idempotency-Key.
                    event_id: "b31f9c2a8e7d4f10"
                providerFailure:
                  summary: Account creation failed downstream
                  value:
                    error: Database error creating new user

  /v1/clients/{clientId}:
    parameters:
      - $ref: '#/components/parameters/ClientId'
    get:
      tags: [ Clients ]
      operationId: getClient
      summary: Get a client
      description: |
        Includes `latest_check_in_at`, which answers *"is this person engaged?"*.

        It does **not** include check-in answers. That is the most sensitive data in the product and
        no stated integration use case needs it.
      responses:
        '200':
          description: The client.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ClientDetail' }
              example:
                client_id: "5f1c..."
                full_name: Jane Smith
                email: jane@example.com
                is_active: true
                current_phase: build
                goals: Lower fasting glucose.
                health_notes: null
                height_in: 66
                weight_lbs: 158
                created_at: "2026-07-02T14:11:03.221Z"
                latest_check_in_at: "2026-08-11T08:30:00.000Z"
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

    patch:
      tags: [ Clients ]
      operationId: updateClient
      summary: Update a client
      description: |
        Today `is_active` is the only settable field - your EMR knows a patient has churned before we
        do, and deactivating from there is the point.

        Nothing about ownership, coach assignment, phase or billing is settable.

        **Reactivation consumes a seat**, so it passes the same plan cap the create path does.
        Without that, deactivate-then-reactivate would be an unmetered way past the limit, and one an
        automation would run on a loop without anyone deciding to.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ is_active ]
              properties:
                is_active:
                  type: boolean
                  description: Whether the client is on the coach's active roster.
            example: { is_active: false }
      responses:
        '200':
          description: |
            The updated client, in the same shape as `GET` minus `latest_check_in_at`. Returned
            unchanged if the value already matched.

            **Neither direction is capped.** A client's seat is held by their stored record, not by
            their status, so deactivating frees nothing and reactivating consumes nothing. This
            endpoint returned `409` on reactivation until 2026-08-26; it no longer can.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ClientProfile' }
        '400':
          description: '`is_active` was missing or was not a boolean.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example: { error: "is_active (boolean) is required." }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }

  /v1/clients/{clientId}/compliance:
    parameters:
      - $ref: '#/components/parameters/ClientId'
    get:
      tags: [ Clients ]
      operationId: getClientCompliance
      summary: Get a client's supplement compliance
      description: |
        The percentage over a rolling window, plus the counts behind it.

        **The default window is 7 days because that is what the automation engine evaluates.** If a
        compliance-based automation tells your system a client dropped, calling this without
        overriding `window_days` gives you the number computed the same way.

        **`compliance_pct` is `null`, not `0`, when a client has logged nothing.** That distinction
        is meaningful: it separates "hasn't engaged" from "engaged badly", and it is why an absent
        client does not trip a compliance automation. Treat null as "no signal".
      parameters:
        - name: window_days
          in: query
          description: Days to look back. Clamped to 90; anything invalid falls back to 7.
          schema: { type: integer, minimum: 1, maximum: 90, default: 7 }
      responses:
        '200':
          description: The client's compliance over the window.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ClientCompliance' }
              example:
                client_id: "5f1c..."
                window_days: 7
                since: "2026-08-10"
                compliance_pct: 61
                doses_logged: 17
                doses_expected: 28
                last_logged_on: "2026-08-16"
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }

  /v1/clients/{clientId}/check-ins:
    parameters:
      - $ref: '#/components/parameters/ClientId'
    get:
      tags: [ Clients ]
      operationId: listClientCheckIns
      summary: List a client's check-ins
      description: |
        When a client checked in and whether their coach has reviewed it. Newest first.

        **No answers, and no score.** The exclusion of check-in content covers the score too, which
        is less obvious than it sounds: the value a low-score automation tests is one of the
        client's answers - a field the coach picked, such as energy or sleep - rather than a summary
        derived from them. Returning it would publish the thing the exclusion exists to protect.

        Use this to reconcile engagement: that a check-in arrived, and when.
      parameters:
        - name: limit
          in: query
          description: Maximum rows. Clamped to 200; anything invalid falls back to 50.
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
      responses:
        '200':
          description: The client's check-in history, without its content.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CheckInList' }
              example:
                client_id: "5f1c..."
                count: 2
                check_ins:
                  - id: "b2f0..."
                    submitted_at: "2026-08-16T08:30:00.000Z"
                    reviewed: true
                    reviewed_at: "2026-08-16T14:02:11.000Z"
                  - id: "a1c9..."
                    submitted_at: "2026-08-09T08:12:00.000Z"
                    reviewed: false
                    reviewed_at: null
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }

  /v1/clients/{clientId}/daily-logs:
    parameters:
      - $ref: '#/components/parameters/ClientId'
    get:
      tags: [ Clients ]
      operationId: listClientDailyLogDates
      summary: List the days a client logged
      description: |
        **Dates only.** A daily log records weight, blood glucose, body temperature and free-text
        notes; none of that is returned, and none of it is read. This answers one question - is this
        person still logging - which is the only question a daily-log automation raises.

        Newest first, so `last_logged_on` is the head of the list.
      parameters:
        - name: limit
          in: query
          description: Maximum dates. Clamped to 365; anything invalid falls back to 90.
          schema: { type: integer, minimum: 1, maximum: 365, default: 90 }
        - name: from
          in: query
          description: Earliest date to include, `YYYY-MM-DD`.
          schema: { type: string, format: date }
        - name: to
          in: query
          description: Latest date to include, `YYYY-MM-DD`.
          schema: { type: string, format: date }
      responses:
        '200':
          description: The days this client logged.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DailyLogDates' }
              example:
                client_id: "5f1c..."
                dates: [ "2026-08-16", "2026-08-15", "2026-08-12" ]
                count: 3
                last_logged_on: "2026-08-16"
        '400':
          description: '`from` or `to` was not a `YYYY-MM-DD` date.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }

  /v1/clients/{clientId}/login-link:
    parameters:
      - $ref: '#/components/parameters/ClientId'
    post:
      tags: [ Clients ]
      operationId: createLoginLink
      summary: Mint a one-click sign-in link
      description: |
        Returns a URL that signs its holder straight into the client's dashboard, for **you** to
        deliver in your own branded SMS or email.

        ## What it is for

        It takes the password out of onboarding, which is where most clients are lost.

        Without it, a new client gets a credentials email from a domain they do not recognise, and
        has to find it, copy a temporary password, open a site and type it in. Every one of those
        steps sheds people at the exact moment they were most motivated.

        With it, the coach's own stack owns the entire first touch:

        1. `POST /v1/clients` with `"send_welcome": false` - we send nothing.
        2. `POST /v1/clients/{clientId}/login-link` - you get a URL.
        3. Your platform sends your branded message: *"Welcome Jane, tap here to open your plan."*

        The client taps and is in. This endpoint and `send_welcome: false` are designed as a pair;
        using either alone gives you half an onboarding.

        ## Treat this as a bearer credential

        The property that makes the above work - no password required - is the same property that
        makes the URL a credential. That is an informed trade for onboarding, not a free
        convenience.

        Anyone who reads the URL - a forwarded text, a shared inbox, an SMS gateway's message log -
        **is that client** until it is used or expires. That is an informed trade for onboarding, not
        a free convenience. Accordingly:

        - It is minted on demand and **never stored**. Nothing in our database holds a usable link.
        - It is single-use; the link is invalidated on first verification.
        - It is rate limited to **20 per minute**, far tighter than the rest of the API, because a
          key that could mint unlimited sign-in links across a roster is a credential-harvesting
          endpoint.
        - It redirects through the coach's canonical host, so a custom-branded client never sees an
          unfamiliar domain on the one link they are asked to trust.

        Deactivated clients are refused - minting them a working link would quietly undo the coach's
        decision from outside the app.
      responses:
        '200':
          description: A single-use sign-in link.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LoginLink' }
              example:
                client_id: "5f1c..."
                login_url: https://drsmith.launchsite-os.com/auth/confirm?token=...
                expires_note: Single use. Expires per the project's auth link lifetime (typically 1 hour).
        '400':
          description: That client has no login email.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: That client is not active.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example: { error: "That client is not active." }
        '429':
          description: More than 20 sign-in links in a minute for this key.
          headers:
            Retry-After:
              schema: { type: integer }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/crm/contacts/{id}:
    patch:
      tags:
        - CRM
      operationId: updateCrmContact
      summary: Update a CRM contact
      description: Requires a separate crm_live_ key with contacts.write. May rename or suppress a contact.
        doNotContact can only be true; the API cannot grant marketing permission or clear suppression.
      security:
        - crmBearerAuth: []
        - crmApiKeyAuth: []
      parameters:
        - $ref: "#/components/parameters/CrmIdempotencyKey"
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                name:
                  type: string
                  minLength: 1
                  maxLength: 200
                doNotContact:
                  const: true
              minProperties: 1
      responses:
        "200":
          description: Persisted or provider-confirmed outcome.
          content:
            application/json:
              schema:
                type: object
                additionalProperties: false
                properties:
                  contact:
                    $ref: "#/components/schemas/CrmContactsRecord"
                required:
                  - contact
        "400":
          description: Invalid fields or missing Idempotency-Key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CrmWriteError"
        "401":
          description: Missing, invalid or revoked CRM key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CrmWriteError"
        "403":
          description: Missing scope, owner configuration, Pro CRM access or gym capability.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CrmWriteError"
        "404":
          description: The record does not belong to this workspace.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CrmWriteError"
        "409":
          description: Conflict, occupied slot, changed idempotent payload or required checkout. checkout_required
            includes bookingUrl.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CrmWriteError"
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          description: Service could not confirm the operation. Retry with the same Idempotency-Key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CrmWriteError"
  /v1/crm/opportunities/{id}:
    patch:
      tags:
        - CRM
      operationId: updateCrmOpportunity
      summary: Update an owned opportunity
      description: Requires a separate crm_live_ key with opportunities.write. Only the key’s owner-configured
        pipeline is eligible. Stage must be an existing open or lost stage in that pipeline; won remains
        payment controlled.
      security:
        - crmBearerAuth: []
        - crmApiKeyAuth: []
      parameters:
        - $ref: "#/components/parameters/CrmIdempotencyKey"
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                name:
                  type: string
                  minLength: 1
                  maxLength: 200
                valueCents:
                  type: integer
                  minimum: 0
                  maximum: 1000000000
                stage:
                  type: string
                  minLength: 1
                  maxLength: 100
                  pattern: '^[a-zA-Z0-9_-]{1,100}$'
                lostReason:
                  type: string
                  minLength: 1
                  maxLength: 500
                  description: Required when selecting a lost stage.
              minProperties: 1
      responses:
        "200":
          description: Persisted or provider-confirmed outcome.
          content:
            application/json:
              schema:
                type: object
                additionalProperties: false
                properties:
                  opportunity:
                    $ref: "#/components/schemas/CrmOpportunitiesRecord"
                required:
                  - opportunity
        "400":
          description: Invalid fields or missing Idempotency-Key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CrmWriteError"
        "401":
          description: Missing, invalid or revoked CRM key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CrmWriteError"
        "403":
          description: Missing scope, owner configuration, Pro CRM access or gym capability.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CrmWriteError"
        "404":
          description: The record does not belong to this workspace.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CrmWriteError"
        "409":
          description: Conflict, occupied slot, changed idempotent payload or required checkout. checkout_required
            includes bookingUrl.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CrmWriteError"
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          description: Service could not confirm the operation. Retry with the same Idempotency-Key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CrmWriteError"
  /v1/crm/bookings/{id}:
    patch:
      tags:
        - CRM
      operationId: rescheduleCrmBooking
      summary: Reschedule a CRM appointment
      description: Requires a separate crm_live_ key with bookings.write. The event must be owned and bound to the
        submitted lead. Availability and conflicts are checked again before changing it.
      security:
        - crmBearerAuth: []
        - crmApiKeyAuth: []
      parameters:
        - $ref: "#/components/parameters/CrmIdempotencyKey"
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                leadId:
                  type: string
                  format: uuid
                date:
                  type: string
                  format: date
                  pattern: ^\d{4}-\d{2}-\d{2}$
                time:
                  type: string
                  pattern: ^([01]\d|2[0-3]):[0-5]\d$
              required:
                - leadId
                - date
                - time
      responses:
        "200":
          description: Persisted or provider-confirmed outcome.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CrmBookingOutcome"
        "400":
          description: Invalid fields or missing Idempotency-Key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CrmWriteError"
        "401":
          description: Missing, invalid or revoked CRM key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CrmWriteError"
        "403":
          description: Missing scope, owner configuration, Pro CRM access or gym capability.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CrmWriteError"
        "404":
          description: The record does not belong to this workspace.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CrmWriteError"
        "409":
          description: Conflict, occupied slot, changed idempotent payload or required checkout. checkout_required
            includes bookingUrl.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CrmWriteError"
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          description: Service could not confirm the operation. Retry with the same Idempotency-Key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CrmWriteError"
    delete:
      tags:
        - CRM
      operationId: cancelCrmBooking
      summary: Cancel a CRM appointment
      description: Requires a separate crm_live_ key with bookings.write. The event must be owned and bound to the
        submitted lead. This operation does not refund or charge a payment.
      security:
        - crmBearerAuth: []
        - crmApiKeyAuth: []
      parameters:
        - $ref: "#/components/parameters/CrmIdempotencyKey"
        - name: id
          in: path
          required: true
          schema: &a1
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                leadId: *a1
              required:
                - leadId
      responses:
        "200":
          description: Persisted or provider-confirmed outcome.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CrmBookingOutcome"
        "400":
          description: Invalid fields or missing Idempotency-Key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CrmWriteError"
        "401":
          description: Missing, invalid or revoked CRM key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CrmWriteError"
        "403":
          description: Missing scope, owner configuration, Pro CRM access or gym capability.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CrmWriteError"
        "404":
          description: The record does not belong to this workspace.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CrmWriteError"
        "409":
          description: Conflict, occupied slot, changed idempotent payload or required checkout. checkout_required
            includes bookingUrl.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CrmWriteError"
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          description: Service could not confirm the operation. Retry with the same Idempotency-Key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CrmWriteError"
  /v1/crm/bookings/availability:
    post:
      tags:
        - CRM
      operationId: getCrmBookingAvailability
      summary: Read available CRM booking slots
      description: Requires a separate crm_live_ key with bookings.read. Reads owned calendars and their current
        slots. Reading does not reserve a slot; booking checks availability again.
      security:
        - crmBearerAuth: []
        - crmApiKeyAuth: []
      parameters: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                leadId:
                  type: string
                  format: uuid
                calendarId:
                  type: string
                  format: uuid
              required:
                - leadId
      responses:
        "200":
          description: Persisted or provider-confirmed outcome.
          content:
            application/json:
              schema:
                type: object
                required:
                  - ok
                  - calendars
                properties:
                  ok:
                    const: true
                  calendars:
                    type: array
                    items:
                      type: object
                      required:
                        - id
                        - name
                        - timezone
                        - priceCents
                        - currency
                        - bookingUrl
                        - days
                      properties:
                        id:
                          type: string
                          format: uuid
                        name: &a2
                          type: string
                        timezone: *a2
                        priceCents:
                          type: integer
                          minimum: 0
                        currency: *a2
                        bookingUrl: *a2
                        days:
                          type: array
                          items:
                            type: object
                            required:
                              - date
                              - slots
                            properties:
                              date:
                                type: string
                                format: date
                              slots:
                                type: array
                                items: *a2
        "400":
          description: Invalid fields or missing Idempotency-Key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CrmWriteError"
        "401":
          description: Missing, invalid or revoked CRM key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CrmWriteError"
        "403":
          description: Missing scope, owner configuration, Pro CRM access or gym capability.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CrmWriteError"
        "404":
          description: The record does not belong to this workspace.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CrmWriteError"
        "409":
          description: Conflict, occupied slot, changed idempotent payload or required checkout. checkout_required
            includes bookingUrl.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CrmWriteError"
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          description: Service could not confirm the operation. Retry with the same Idempotency-Key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CrmWriteError"
  /v1/crm/workflows/{key}/enroll:
    post:
      tags:
        - CRM
      operationId: enrollCrmWorkflow
      summary: Enroll a lead in an allowed reviewed workflow
      description: Requires a separate crm_live_ key with workflows.enroll. The key owner must explicitly allow this
        workflow. It must remain active, manual, and review-only; the API cannot activate workflows,
        impersonate member events, grant consent or send an email.
      security:
        - crmBearerAuth: []
        - crmApiKeyAuth: []
      parameters:
        - $ref: "#/components/parameters/CrmIdempotencyKey"
        - name: key
          in: path
          required: true
          schema:
            type: string
            minLength: 1
            maxLength: 200
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                leadId:
                  type: string
                  format: uuid
              required:
                - leadId
      responses:
        "200":
          description: Persisted or provider-confirmed outcome.
          content:
            application/json:
              schema:
                type: object
                additionalProperties: false
                properties:
                  enrollment:
                    type: object
                    additionalProperties: false
                    properties:
                      created:
                        type: boolean
                      reason:
                        type: string
                    required:
                      - created
                required:
                  - enrollment
        "400":
          description: Invalid fields or missing Idempotency-Key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CrmWriteError"
        "401":
          description: Missing, invalid or revoked CRM key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CrmWriteError"
        "403":
          description: Missing scope, owner configuration, Pro CRM access or gym capability.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CrmWriteError"
        "404":
          description: The record does not belong to this workspace.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CrmWriteError"
        "409":
          description: Conflict, occupied slot, changed idempotent payload or required checkout. checkout_required
            includes bookingUrl.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CrmWriteError"
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          description: Service could not confirm the operation. Retry with the same Idempotency-Key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CrmWriteError"

  /v1/crm/website-submissions:
    get:
      tags: [ CRM ]
      operationId: getCrmWebsiteFormContract
      summary: Read a published form's contract for a website
      description: |
        Requires a crm_live_ key with submissions.write. Returns the published form's mapped questions with
        their EXACT option lists and the consent text the website must display verbatim. LaunchSite option
        sets are canonical: a website renders these rather than copying values by hand.
      security: [ { crmBearerAuth: [] }, { crmApiKeyAuth: [] } ]
      parameters:
        - name: form
          in: query
          required: true
          schema: { type: string, pattern: '^[a-z0-9-]{3,60}$' }
          description: The published form's slug.
      responses:
        '200':
          description: The form contract.
          content:
            application/json:
              schema:
                type: object
                required: [ form ]
                properties:
                  form:
                    type: object
                    required: [ id, slug, title, published_at, consent_text, questions ]
                    properties:
                      id: { type: string, format: uuid }
                      slug: { type: string }
                      title: { type: string }
                      published_at: { type: string, format: date-time }
                      consent_text: { type: string }
                      questions:
                        type: array
                        items:
                          type: object
                          required: [ target, label, type, required, options, sensitive ]
                          properties:
                            target: { type: string, description: 'The answer key to send: name, email, phone, primaryGoal, obstacle, notes, or custom:<key>.' }
                            label: { type: string }
                            type: { type: string, enum: [ text, textarea, select, multi, email, phone, hidden ] }
                            required: { type: boolean }
                            options: { type: [ array, 'null' ], items: { type: string } }
                            sensitive: { type: boolean }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: "Missing submissions.write, ProCRM entitlement or enabled gym capability." }
        '404': { description: "The form is not published in this key's workspace." }
        '429': { $ref: '#/components/responses/RateLimited' }
    post:
      tags: [ CRM ]
      operationId: createCrmWebsiteSubmission
      summary: Submit a website form or acquisition event
      description: |
        Requires a crm_live_ key with submissions.write. A coach's OWN website submits facts server-to-server:
        the person is resolved through the contact spine by normalised email (merged aliases resolve to the
        survivor), the sales cycle is resolved as MATCHED / NO_MATCH / AMBIGUOUS (one open opportunity is
        continued; none opens a new one, a returning client's Won cycle untouched; two or more parks the
        submission for review with a task), the published form's answer mapping and post-submit actions run
        exactly as the native form's would, and attribution is recorded on the contact (first touch,
        write-once), the opportunity (acquisition utm write-once, last_touch) and the event.

        The website never decides stage, labels, Roadmap Fit, ownership or nurture; such keys are refused
        (forbidden_field). Option values must match the published question exactly (enum_mismatch).
        `Idempotency-Key` is required; the same key with a different body is refused (idempotency_conflict).
        The site must be registered for the key's workspace (site_not_found otherwise).
      security: [ { crmBearerAuth: [] }, { crmApiKeyAuth: [] } ]
      parameters:
        - $ref: '#/components/parameters/CrmIdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [ site, event, occurred_at, client_submission_id ]
              properties:
                site:
                  type: object
                  additionalProperties: false
                  required: [ site_key ]
                  properties:
                    site_key: { type: string, pattern: '^[a-z][a-z0-9_]{1,39}$' }
                    brand_id: { type: string, pattern: '^[a-z][a-z0-9_]{1,39}$' }
                    domain: { type: string, maxLength: 253 }
                form: { type: string, pattern: '^[a-z0-9-]{3,60}$', description: 'Published form slug. Required for every event except page_view.' }
                event: { type: string, enum: [ application_submitted, quiz_captured, page_view, quiz_started, quiz_completed, application_started ] }
                occurred_at: { type: string, format: date-time }
                client_submission_id: { type: string, pattern: '^[A-Za-z0-9_-]{8,120}$' }
                page: { type: string, maxLength: 500, pattern: '^/[^?#]*$', description: 'Path only; never a query string.' }
                identity:
                  type: object
                  additionalProperties: false
                  required: [ email ]
                  description: Required for application_submitted and quiz_captured; forbidden on anonymous events.
                  properties:
                    email: { type: string, format: email, maxLength: 254 }
                    first_name: { type: string, maxLength: 80 }
                    last_name: { type: string, maxLength: 80 }
                    phone: { type: string, maxLength: 40 }
                    opportunity_token: { type: string, maxLength: 2048, description: 'A signed personal-link token naming the sales cycle to continue.' }
                answers:
                  type: object
                  description: Keyed by the question target from GET (name, email, phone, primaryGoal, obstacle, notes, custom:<key>). Values must be the form's exact options.
                  additionalProperties:
                    oneOf:
                      - { type: string, maxLength: 2000 }
                      - { type: array, maxItems: 25, items: { type: string, maxLength: 200 } }
                attribution:
                  type: object
                  additionalProperties: false
                  properties:
                    version: { type: string, maxLength: 20 }
                    first_touch_at: { type: [ string, 'null' ], format: date-time }
                    first_touch_source: { type: [ string, 'null' ], maxLength: 200 }
                    first_touch_landing_page: { type: [ string, 'null' ], maxLength: 500 }
                    first_touch_referrer_host: { type: [ string, 'null' ], maxLength: 253 }
                    last_touch_at: { type: [ string, 'null' ], format: date-time }
                    last_touch_source: { type: [ string, 'null' ], maxLength: 200 }
                    landing_page: { type: [ string, 'null' ], maxLength: 500 }
                    referrer_host: { type: [ string, 'null' ], maxLength: 253 }
                    utm_source: { type: [ string, 'null' ], maxLength: 200 }
                    utm_medium: { type: [ string, 'null' ], maxLength: 200 }
                    utm_campaign: { type: [ string, 'null' ], maxLength: 200 }
                    utm_content: { type: [ string, 'null' ], maxLength: 200 }
                    utm_term: { type: [ string, 'null' ], maxLength: 200 }
                    click_ids: { type: object, additionalProperties: { type: string, maxLength: 200 } }
                consent:
                  type: object
                  additionalProperties: false
                  required: [ accepted, text_version, accepted_at ]
                  description: Required for conversion events. The stored consent text is the form's canonical text; text_version is the revision the website rendered.
                  properties:
                    accepted: { type: boolean, enum: [ true ] }
                    text_version: { type: string, maxLength: 120 }
                    accepted_at: { type: string, format: date-time }
      responses:
        '201':
          description: "The conversion was recorded and attached to a resolved person and sales cycle. A `replayed` flag of true marks an idempotent replay."
          content:
            application/json:
              schema:
                type: object
                required: [ submission, contact, opportunity, resolution, form_actions ]
                properties:
                  submission: { type: object, required: [ id ], properties: { id: { type: string, format: uuid } } }
                  contact: { type: object, required: [ id, created ], properties: { id: { type: string, format: uuid }, created: { type: boolean } } }
                  opportunity: { type: object, required: [ id, created, stage, reactivation ], properties: { id: { type: string, format: uuid }, created: { type: boolean }, stage: { type: string }, reactivation: { type: boolean } } }
                  resolution: { type: object, required: [ contact, opportunity, reason ], properties: { contact: { type: string, enum: [ MATCHED, NO_MATCH ] }, opportunity: { type: string, enum: [ MATCHED, NO_MATCH ] }, reason: { type: string } } }
                  form_actions: { type: object, required: [ applied, skipped ], properties: { applied: { type: array, items: { type: string } }, skipped: { type: array, items: { type: string } } } }
                  replayed: { type: boolean }
        '202':
          description: "Recorded but not attached: an anonymous event, or a conversion parked for review because the person or the sales cycle is ambiguous (review.required, resolution.candidates)."
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        '400':
          description: "invalid_request, forbidden_field, unknown_field, enum_mismatch, required_missing, identity_required, consent_required, form_required, or a missing Idempotency-Key. The fields array names the offending keys."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CrmWriteError' }
        '401':
          description: "Missing, invalid or revoked CRM key, or an invalid/foreign opportunity_token (identity_token_invalid)."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CrmWriteError' }
        '403': { description: "Missing submissions.write, ProCRM entitlement or enabled gym capability." }
        '404':
          description: "site_not_found or form_not_found for this key's workspace."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CrmWriteError' }
        '409':
          description: "idempotency_conflict (same key, different body) or request_in_flight."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CrmWriteError' }
        '413': { description: "Body larger than 32 KiB." }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503':
          description: "The submission could not be confirmed; nothing partial is reported as success. Retry with the same Idempotency-Key."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CrmWriteError' }

webhooks:
  # -- EMITTED EVENTS --------------------------------------------------------------------
  #
  # These happen at a definite instant, the same instant for everybody, so an endpoint can
  # simply subscribe: Settings -> Private Integrations -> Send these events. No automation
  # is involved.
  new_client:
    post:
      summary: A client was created
      operationId: onNewClient
      description: |
        A client record now exists - through the app, an intake, or `POST /v1/clients` itself. This is the event to hang onboarding off.
      x-launchsite-reachable-by: subscription
      x-launchsite-recover-with: /v1/clients/{clientId}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ type, timestamp, data ]
              properties:
                type:
                  const: new_client
                  description: Route on this. It is the only field guaranteed present on every delivery.
                timestamp:
                  type: string
                  format: date-time
                  description: When we built the envelope. The signed seconds form is in the `webhook-timestamp` header.
                data:
                  type: object
                  properties:
                    client: { $ref: '#/components/schemas/EventClient' }
      responses:
        '2xx':
          description: |
            Any 2xx means accepted and we stop. Anything else is retried 8 times over roughly a day;
            `410 Gone` stops us permanently. Answer fast and do the work afterwards.
  check_in_submitted:
    post:
      summary: A client submitted a check-in
      operationId: onCheckInSubmitted
      description: |
        A check-in was submitted. The **answers are not here** and there is no setting that adds them: fetch them with a scoped key using `check_in_id`.
      x-launchsite-reachable-by: subscription
      x-launchsite-recover-with: /v1/clients/{clientId}/check-ins
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ type, timestamp, data ]
              properties:
                type:
                  const: check_in_submitted
                  description: Route on this. It is the only field guaranteed present on every delivery.
                timestamp:
                  type: string
                  format: date-time
                  description: When we built the envelope. The signed seconds form is in the `webhook-timestamp` header.
                data:
                  type: object
                  properties:
                    client: { $ref: '#/components/schemas/EventClient' }
                    check_in_id:
                      type: string
                      description: The check-in to fetch. The answers live behind the REST read, never in this payload.
      responses:
        '2xx':
          description: |
            Any 2xx means accepted and we stop. Anything else is retried 8 times over roughly a day;
            `410 Gone` stops us permanently. Answer fast and do the work afterwards.
  low_check_in_score:
    post:
      summary: A check-in scored below the built-in floor
      operationId: onLowCheckInScore
      description: |
        A submitted check-in scored low against the platform floor. Distinct from `compliance_drop`, which is measured against a threshold the coach sets.
      x-launchsite-reachable-by: subscription
      x-launchsite-recover-with: /v1/clients/{clientId}/check-ins
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ type, timestamp, data ]
              properties:
                type:
                  const: low_check_in_score
                  description: Route on this. It is the only field guaranteed present on every delivery.
                timestamp:
                  type: string
                  format: date-time
                  description: When we built the envelope. The signed seconds form is in the `webhook-timestamp` header.
                data:
                  type: object
                  properties:
                    client: { $ref: '#/components/schemas/EventClient' }
                    check_in_id:
                      type: string
                      description: The check-in that scored low.
      responses:
        '2xx':
          description: |
            Any 2xx means accepted and we stop. Anything else is retried 8 times over roughly a day;
            `410 Gone` stops us permanently. Answer fast and do the work afterwards.
  system_grade_drop:
    post:
      summary: A system grade fell
      operationId: onSystemGradeDrop
      description: |
        One of the per-body-system grades dropped. **The grades themselves are never sent** - they are a health assessment, so this event tells you it happened and nothing about what it said.
      x-launchsite-reachable-by: subscription
      x-launchsite-recover-with: /v1/clients/{clientId}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ type, timestamp, data ]
              properties:
                type:
                  const: system_grade_drop
                  description: Route on this. It is the only field guaranteed present on every delivery.
                timestamp:
                  type: string
                  format: date-time
                  description: When we built the envelope. The signed seconds form is in the `webhook-timestamp` header.
                data:
                  type: object
                  properties:
                    client: { $ref: '#/components/schemas/EventClient' }
      responses:
        '2xx':
          description: |
            Any 2xx means accepted and we stop. Anything else is retried 8 times over roughly a day;
            `410 Gone` stops us permanently. Answer fast and do the work afterwards.
  phase_advanced:
    post:
      summary: A client moved to a new phase
      operationId: onPhaseAdvanced
      description: |
        The client advanced. Phase names are coach-authored programme labels, so both the old and the new name travel with the event.
      x-launchsite-reachable-by: subscription
      x-launchsite-recover-with: /v1/clients/{clientId}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ type, timestamp, data ]
              properties:
                type:
                  const: phase_advanced
                  description: Route on this. It is the only field guaranteed present on every delivery.
                timestamp:
                  type: string
                  format: date-time
                  description: When we built the envelope. The signed seconds form is in the `webhook-timestamp` header.
                data:
                  type: object
                  properties:
                    client: { $ref: '#/components/schemas/EventClient' }
                    current_phase:
                      type: string
                      description: The phase they were on.
                    new_phase:
                      type: string
                      description: The phase they are on now.
      responses:
        '2xx':
          description: |
            Any 2xx means accepted and we stop. Anything else is retried 8 times over roughly a day;
            `410 Gone` stops us permanently. Answer fast and do the work afterwards.
  message_received:
    post:
      summary: A client sent a message
      operationId: onMessageReceived
      description: |
        A client sent a message. **The body is never sent.** You get the id and the fact it happened; read the conversation in the app.
      x-launchsite-reachable-by: subscription
      x-launchsite-recover-with: /v1/clients/{clientId}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ type, timestamp, data ]
              properties:
                type:
                  const: message_received
                  description: Route on this. It is the only field guaranteed present on every delivery.
                timestamp:
                  type: string
                  format: date-time
                  description: When we built the envelope. The signed seconds form is in the `webhook-timestamp` header.
                data:
                  type: object
                  properties:
                    client: { $ref: '#/components/schemas/EventClient' }
                    message_id:
                      type: string
                      description: Identifies the message. The body is not exposed over this API.
      responses:
        '2xx':
          description: |
            Any 2xx means accepted and we stop. Anything else is retried 8 times over roughly a day;
            `410 Gone` stops us permanently. Answer fast and do the work afterwards.
  # -- COMPUTED EVENTS -------------------------------------------------------------------
  #
  # These have NO moment of their own. "Compliance dropped" is a judgement against a
  # threshold the COACH chose, for an audience they chose, found by a sweep - so there is
  # nothing to subscribe to, and no checkbox for them exists. A coach reaches one by adding
  # a Webhook step to the automation that defines the threshold, which is also why every
  # payload below carries `automation`.
  #
  # If you are hunting for a compliance_drop subscription: this is why you cannot find one.
  # The feature is not half-built.
  compliance_drop:
    post:
      summary: Compliance fell below the threshold an automation set
      operationId: onComplianceDrop
      description: |
        Compliance fell below **the number the coach chose**, which is why `automation.threshold` travels with it: 61% is not bad in itself.
      x-launchsite-reachable-by: automation
      x-launchsite-recover-with: /v1/clients/{clientId}/compliance
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ type, timestamp, data ]
              properties:
                type:
                  const: compliance_drop
                  description: Route on this. It is the only field guaranteed present on every delivery.
                timestamp:
                  type: string
                  format: date-time
                  description: When we built the envelope. The signed seconds form is in the `webhook-timestamp` header.
                data:
                  type: object
                  properties:
                    client: { $ref: '#/components/schemas/EventClient' }
                    compliance_pct:
                      type: number
                      description: The figure that tripped the rule.
                    automation: { $ref: '#/components/schemas/EventAutomation' }
      responses:
        '2xx':
          description: |
            Any 2xx means accepted and we stop. Anything else is retried 8 times over roughly a day;
            `410 Gone` stops us permanently. Answer fast and do the work afterwards.
  compliance_milestone:
    post:
      summary: Compliance reached a milestone an automation set
      operationId: onComplianceMilestone
      description: |
        Compliance reached the milestone the coach set. The same figure, read the other way.
      x-launchsite-reachable-by: automation
      x-launchsite-recover-with: /v1/clients/{clientId}/compliance
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ type, timestamp, data ]
              properties:
                type:
                  const: compliance_milestone
                  description: Route on this. It is the only field guaranteed present on every delivery.
                timestamp:
                  type: string
                  format: date-time
                  description: When we built the envelope. The signed seconds form is in the `webhook-timestamp` header.
                data:
                  type: object
                  properties:
                    client: { $ref: '#/components/schemas/EventClient' }
                    compliance_pct:
                      type: number
                      description: The figure that met the milestone.
                    automation: { $ref: '#/components/schemas/EventAutomation' }
      responses:
        '2xx':
          description: |
            Any 2xx means accepted and we stop. Anything else is retried 8 times over roughly a day;
            `410 Gone` stops us permanently. Answer fast and do the work afterwards.
  phase_eligible:
    post:
      summary: A client became eligible to advance
      operationId: onPhaseEligible
      description: |
        The client now meets the automation rule for advancing. They have **not** advanced - `phase_advanced` is that event.
      x-launchsite-reachable-by: automation
      x-launchsite-recover-with: /v1/clients/{clientId}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ type, timestamp, data ]
              properties:
                type:
                  const: phase_eligible
                  description: Route on this. It is the only field guaranteed present on every delivery.
                timestamp:
                  type: string
                  format: date-time
                  description: When we built the envelope. The signed seconds form is in the `webhook-timestamp` header.
                data:
                  type: object
                  properties:
                    client: { $ref: '#/components/schemas/EventClient' }
                    current_phase:
                      type: string
                      description: The phase they are eligible to leave.
                    automation: { $ref: '#/components/schemas/EventAutomation' }
      responses:
        '2xx':
          description: |
            Any 2xx means accepted and we stop. Anything else is retried 8 times over roughly a day;
            `410 Gone` stops us permanently. Answer fast and do the work afterwards.
  check_in_inactivity:
    post:
      summary: A client stopped checking in
      operationId: onCheckInInactivity
      description: |
        No check-in for as long as the automation allows. A coach-triggered re-engagement sends `days_inactive` instead of a missed count.
      x-launchsite-reachable-by: automation
      x-launchsite-recover-with: /v1/clients/{clientId}/check-ins
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ type, timestamp, data ]
              properties:
                type:
                  const: check_in_inactivity
                  description: Route on this. It is the only field guaranteed present on every delivery.
                timestamp:
                  type: string
                  format: date-time
                  description: When we built the envelope. The signed seconds form is in the `webhook-timestamp` header.
                data:
                  type: object
                  properties:
                    client: { $ref: '#/components/schemas/EventClient' }
                    missed_check_ins:
                      type: integer
                      description: Check-ins missed against their cadence.
                    days_inactive:
                      type: integer
                      description: Sent instead of a missed count when a coach starts re-engagement by hand.
                    last_check_in_date:
                      type: string
                      description: ISO date of their last check-in, when there is one.
                    check_in_cadence:
                      type: string
                      description: How often this client is expected to check in.
                    automation: { $ref: '#/components/schemas/EventAutomation' }
      responses:
        '2xx':
          description: |
            Any 2xx means accepted and we stop. Anything else is retried 8 times over roughly a day;
            `410 Gone` stops us permanently. Answer fast and do the work afterwards.
  daily_log_inactivity:
    post:
      summary: A client stopped logging days
      operationId: onDailyLogInactivity
      description: |
        No daily log for as long as the automation allows.
      x-launchsite-reachable-by: automation
      x-launchsite-recover-with: /v1/clients/{clientId}/daily-logs
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ type, timestamp, data ]
              properties:
                type:
                  const: daily_log_inactivity
                  description: Route on this. It is the only field guaranteed present on every delivery.
                timestamp:
                  type: string
                  format: date-time
                  description: When we built the envelope. The signed seconds form is in the `webhook-timestamp` header.
                data:
                  type: object
                  properties:
                    client: { $ref: '#/components/schemas/EventClient' }
                    missed_days:
                      type: integer
                      description: Days missed.
                    last_daily_log_date:
                      type: string
                      description: ISO date of their last daily log, when there is one.
                    automation: { $ref: '#/components/schemas/EventAutomation' }
      responses:
        '2xx':
          description: |
            Any 2xx means accepted and we stop. Anything else is retried 8 times over roughly a day;
            `410 Gone` stops us permanently. Answer fast and do the work afterwards.
  # -- NOT A CLIENT EVENT: THE CRM PIPELINE ----------------------------------------------------
  crm_workflow:
    post:
      summary: A CRM workflow reached a Webhook step
      operationId: onCrmWorkflow
      description: |
        Sent by a **Webhook** step inside one of the coach's CRM workflows. This is the only event
        about a **lead** rather than a client, so `data` carries `lead` and `workflow` where every
        other event carries `client`.

        A lead is someone in the coach's pipeline who is not yet a client. `lead.id` is a CRM lead
        id and is **not** interchangeable with a `client_id` anywhere else in this document.
      x-launchsite-reachable-by: automation
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ type, timestamp, data ]
              properties:
                type:
                  const: crm_workflow
                  description: Route on this. It is the only field guaranteed present on every delivery.
                timestamp:
                  type: string
                  format: date-time
                  description: When we built the envelope. The signed seconds form is in the `webhook-timestamp` header.
                data:
                  type: object
                  properties:
                    lead: { $ref: '#/components/schemas/EventLead' }
                    workflow:
                      type: object
                      description: The workflow that sent this, so two of them into one endpoint stay distinguishable.
                      properties:
                        key: { type: string }
                        name: { type: string }
      responses:
        '2xx':
          description: |
            Any 2xx means accepted and we stop. Anything else is retried 8 times over roughly a day;
            `410 Gone` stops us permanently. Answer fast and do the work afterwards.
  # -- NOT CLIENT EVENTS, BUT THEY REACH THE WIRE ----------------------------------------
  automation_triggered:
    post:
      summary: An automation fired from something with no client-event name
      operationId: onAutomationTriggered
      description: |
        A schedule, a birthday, a holiday, a manual run, or a legacy trigger. Real deliveries that
        simply have no client-event name to report.

        These used to be labelled `test`, which tells receivers to drop them - so a scheduled
        automation posted something documented to be ignored. Handle this branch, or you will
        silently discard live traffic.
      x-launchsite-reachable-by: automation
      x-launchsite-recover-with: /v1/clients/{clientId}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ type, timestamp, data ]
              properties:
                type:
                  const: automation_triggered
                  description: Route on this. It is the only field guaranteed present on every delivery.
                timestamp:
                  type: string
                  format: date-time
                  description: When we built the envelope. The signed seconds form is in the `webhook-timestamp` header.
                data:
                  type: object
                  properties:
                    client: { $ref: '#/components/schemas/EventClient' }
                    automation: { $ref: '#/components/schemas/EventAutomation' }
      responses:
        '2xx':
          description: |
            Any 2xx means accepted and we stop. Anything else is retried 8 times over roughly a day;
            `410 Gone` stops us permanently. Answer fast and do the work afterwards.
  test:
    post:
      summary: A test delivery from the Send test button
      operationId: onTest
      description: |
        Sent by **Send test** on the endpoint card. Real signature, real shape, synthetic content:
        an all-zero client id and an `example.com` address.

        **Never act on this.** A receiver that treats it as real starts a re-engagement campaign
        against a person who does not exist.
      x-launchsite-reachable-by: subscription
      x-launchsite-recover-with: /v1/ping
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ type, timestamp, data ]
              properties:
                type:
                  const: test
                  description: Route on this. It is the only field guaranteed present on every delivery.
                timestamp:
                  type: string
                  format: date-time
                  description: When we built the envelope. The signed seconds form is in the `webhook-timestamp` header.
                data:
                  type: object
                  properties:
                    client: { $ref: '#/components/schemas/EventClient' }
      responses:
        '2xx':
          description: |
            Any 2xx means accepted and we stop. Anything else is retried 8 times over roughly a day;
            `410 Gone` stops us permanently. Answer fast and do the work afterwards.

components:
  securitySchemes:
    crmBearerAuth:
      type: http
      scheme: bearer
      description: 'CRM key with the operation-specific selected grant: Authorization: Bearer crm_live_...'
    crmApiKeyAuth:
      type: apiKey
      in: header
      name: X-Api-Key
      description: 'Equivalent CRM key header: X-Api-Key: crm_live_...'
    bearerAuth:
      type: http
      scheme: bearer
      description: '`Authorization: Bearer lsk_live_...`'
    apiKeyAuth:
      type: apiKey
      in: header
      name: X-Api-Key
      description: '`X-Api-Key: lsk_live_...` - equivalent to the bearer form.'

  parameters:
    CrmCursor:
      name: cursor
      in: query
      description: The previous response next_cursor. Omit for the first page.
      schema: { type: string, format: uuid }
    CrmLimit:
      name: limit
      in: query
      description: Maximum records per page.
      schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
    ClientId:
      name: clientId
      in: path
      required: true
      description: The client's `client_id`, as returned by the create and list endpoints.
      schema: { type: string }
    CrmIdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      description: Stable key for this exact operation. Reuse the same key and payload after an uncertain result; a
        changed payload conflicts.
      schema:
        type: string
        minLength: 1
        maxLength: 200

  responses:
    BadRequest:
      description: A parameter failed validation.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Unauthorized:
      description: The API key is missing, malformed, revoked, or minted for a different scope.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example:
            error: >-
              Missing or invalid API key. Send it as "Authorization: Bearer lsk_live_…" or "X-Api-Key". Keys
              are created under Settings → Private Integrations.
    PaymentRequired:
      description: |
        The workspace has no active subscription. The **request** is paused, not the credential -
        keys survive a lapse so re-subscribing does not mean re-pasting every integration.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example:
            error: This workspace does not have an active subscription. The API is paused until billing is resolved.
    NotFound:
      description: No client with that id belongs to this key's coach.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { error: "Client not found." }
    RateLimited:
      description: The key exceeded its request budget.
      headers:
        Retry-After:
          description: Seconds to wait before retrying.
          schema: { type: integer }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { error: "Too many requests. This key is limited to 300 requests per minute." }
    ServerError:
      description: Our failure. `event_id`, when present, can be quoted to support.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }

  schemas:
    CrmContactsRecord:
      type: object
      required: [ id ]
      properties:
        id: { type: string, format: uuid }
        name: { type: [ string, 'null' ] }
        email: { type: [ string, 'null' ] }
        lifecycle_type: { type: [ string, 'null' ] }
        do_not_contact: { type: boolean }
        client_id: { type: [ string, 'null' ] }
        created_at: { type: [ string, 'null' ] }
        updated_at: { type: [ string, 'null' ] }

    CrmOpportunitiesRecord:
      type: object
      required: [ id ]
      properties:
        id: { type: string, format: uuid }
        contact_id: { type: [ string, 'null' ] }
        name: { type: [ string, 'null' ] }
        email: { type: [ string, 'null' ] }
        pipeline_id: { type: [ string, 'null' ] }
        stage: { type: [ string, 'null' ] }
        value_cents: { type: [ integer, 'null' ] }
        payment_status: { type: [ string, 'null' ] }
        opted_out: { type: boolean }
        email_consent_pending: { type: boolean }
        linked_client_id: { type: [ string, 'null' ] }
        created_at: { type: [ string, 'null' ] }
        updated_at: { type: [ string, 'null' ] }

    CrmCalendarsRecord:
      type: object
      required: [ id ]
      properties:
        id: { type: string, format: uuid }
        name: { type: [ string, 'null' ] }
        slug: { type: [ string, 'null' ] }
        description: { type: [ string, 'null' ] }
        duration_minutes: { type: [ integer, 'null' ] }
        active: { type: boolean }
        calendar_type: { type: [ string, 'null' ] }
        price_cents: { type: [ integer, 'null' ] }
        currency: { type: [ string, 'null' ] }

    CrmBookingsRecord:
      type: object
      required: [ id ]
      properties:
        id: { type: string, format: uuid }
        calendar_id: { type: [ string, 'null' ] }
        lead_id: { type: [ string, 'null' ] }
        client_id: { type: [ string, 'null' ] }
        title: { type: [ string, 'null' ] }
        event_date: { type: [ string, 'null' ] }
        event_time: { type: [ string, 'null' ] }
        booking_starts_at: { type: [ string, 'null' ] }
        booking_timezone: { type: [ string, 'null' ] }
        booking_duration_minutes: { type: [ integer, 'null' ] }
        payment_status: { type: [ string, 'null' ] }
        no_show_at: { type: [ string, 'null' ] }
        created_at: { type: [ string, 'null' ] }
        updated_at: { type: [ string, 'null' ] }

    CrmWorkflowsRecord:
      type: object
      required: [ id ]
      properties:
        id: { type: string, format: uuid }
        key: { type: [ string, 'null' ] }
        name: { type: [ string, 'null' ] }
        description: { type: [ string, 'null' ] }
        trigger: { type: [ string, 'null' ] }
        trigger_config: { type: object, additionalProperties: true }
        is_active: { type: boolean }
        created_at: { type: [ string, 'null' ] }
        updated_at: { type: [ string, 'null' ] }
    EventLead:
      type: object
      description: |
        A lead in the coach's CRM pipeline. **Not a client** - `id` is a CRM lead id and does not
        resolve against any `/v1/clients` endpoint.

        As with `EventClient`, the endpoint's payload mode decides whether contact details travel.
        `custom` and `utm` travel in both modes and are deliberately not filtered: `custom` is
        coach-defined and `utm` is whatever their capture form collected, so we cannot know what is
        in either. The subprocessor disclosure says exactly that rather than claiming a limit we do
        not enforce.
      required: [ id, stage ]
      properties:
        id:
          type: string
          description: The CRM lead id.
        stage:
          type: string
          description: Where the lead sits in the coach's pipeline.
        value_cents:
          type: integer
          description: Deal value in cents. `0` when unset.
        source:
          type: [ string, 'null' ]
          description: Where the lead came from.
        created_at:
          type: [ string, 'null' ]
          format: date-time
        utm:
          type: [ object, 'null' ]
          description: Marketing attribution captured with the lead. Unfiltered.
          additionalProperties: { type: string }
        labels:
          type: array
          description: The coach's label names for this lead.
          items: { type: string }
        custom:
          type: object
          description: Coach-defined custom fields and their values. Unfiltered - a coach who records health notes here
            sends them.
          additionalProperties: true
        name:
          type: [ string, 'null' ]
          description: '`contact` mode only.'
        email:
          type: [ string, 'null' ]
          description: '`contact` mode only.'
        phone:
          type: [ string, 'null' ]
          description: '`contact` mode only.'
    EventClient:
      type: object
      description: |
        The client an event concerns. **What is in here is decided by the endpoint's payload mode,
        chosen when the coach created it - not by anything you send.**

        `identifiers` carries the id alone. `contact` adds name, email and phone, because
        GoHighLevel, Zapier and Make resolve a webhook to their own contact record by matching on
        email or phone; handed only our id, those workflows match nothing and run against no one,
        with no error anywhere the coach would look.

        Omitted entirely when an event concerns no client - a schedule-driven automation, say.
        There is no placeholder id, because a synthetic one is indistinguishable from a real one in
        the field you join on.
      required: [ id ]
      properties:
        id:
          type: string
          description: The client's `client_id`. The same value every REST read here takes.
        name:
          type: [ string, 'null' ]
          description: '`contact` mode only. Absent in `identifiers`.'
        email:
          type: [ string, 'null' ]
          description: '`contact` mode only. The field GoHighLevel and Zapier match on.'
        phone:
          type: [ string, 'null' ]
          description: '`contact` mode only.'

    EventAutomation:
      type: object
      description: |
        The automation that produced this delivery. Present on every Computed event, and on
        anything sent by a Webhook step.

        **Not decoration.** A Computed event is meaningless without the rule that decided it:
        `compliance_pct: 61` says nothing about whether 61 is bad - the coach's threshold is what
        made it an event. And a coach running two compliance rules at different thresholds would
        otherwise receive two identical-looking deliveries with no way to tell them apart.
      required: [ id ]
      properties:
        id: { type: string }
        name:
          type: [ string, 'null' ]
          description: What the coach called it.
        threshold:
          type: number
          description: The number the rule tripped on. Absent when the rule has none.
    Error:
      type: object
      required: [ error ]
      description: Every error on this API has this shape, so you can parse one thing.
      properties:
        error:
          type: string
          description: A human-readable explanation. Not a stable machine identifier - branch on the status code.
        event_id:
          type: string
          description: Present on some 5xx responses. Quote it to support to find the exact failure.
      additionalProperties: true

    PingResult:
      type: object
      required: [ ok, coach, active_clients ]
      properties:
        ok: { type: boolean, const: true }
        coach:
          type: object
          required: [ id ]
          description: The coach this key resolved to. Check it matches the workspace you meant.
          properties:
            id: { type: string }
            name: { type: [ string, 'null' ] }
            email: { type: [ string, 'null' ] }
        active_clients:
          type: integer
          description: Active, non-demo clients on this coach's roster.

    Client:
      type: object
      required: [ client_id, full_name, email, is_active, current_phase, created_at ]
      properties:
        client_id: { type: string }
        full_name: { type: string }
        email: { type: string, format: email }
        is_active: { type: boolean }
        current_phase:
          type: [ string, 'null' ]
          description: The coach's programme phase for this client, or null if unset.
        created_at: { type: string, format: date-time }

    ClientProfile:
      description: |
        A client with their profile fields. Returned by `PATCH`, and by `GET` with
        `latest_check_in_at` added.
      allOf:
        - $ref: '#/components/schemas/Client'
        - type: object
          required: [ goals, health_notes, height_in, weight_lbs ]
          properties:
            goals: { type: [ string, 'null' ] }
            health_notes: { type: [ string, 'null' ] }
            height_in: { type: [ number, 'null' ] }
            weight_lbs: { type: [ number, 'null' ] }

    ClientDetail:
      allOf:
        - $ref: '#/components/schemas/ClientProfile'
        - type: object
          required: [ latest_check_in_at ]
          properties:
            latest_check_in_at:
              type: [ string, 'null' ]
              format: date-time
              description: |
                When the client last submitted a check-in, or null if never. The **date only** -
                check-in answers are not exposed to a partner key.

    ClientList:
      type: object
      required: [ clients, count ]
      properties:
        clients:
          type: array
          items: { $ref: '#/components/schemas/Client' }
        count:
          type: integer
          description: Rows in this response - not the total matching the filter.

    CreateClientRequest:
      type: object
      required: [ email ]
      description: |
        **A name is required**, supplied as either `full_name` or its alias `name` - send one of the
        two. They cannot both be listed in `required`, so the constraint is expressed as `anyOf`
        below; omitting both returns `400`.

        Every string field is **rejected** if it exceeds its maximum - not truncated.
      anyOf:
        - required: [ full_name ]
        - required: [ name ]
      properties:
        full_name: { type: string, minLength: 1, maxLength: 120, example: Jane Smith }
        name:
          type: string
          maxLength: 120
          description: Alias for `full_name`. Used only when `full_name` is absent.
        email: { type: string, format: email, maxLength: 160, example: jane@example.com }
        phone:
          type: string
          maxLength: 40
          description: |
            Used to reconcile against an existing CRM lead, so a patient the coach already had as a
            lead is linked rather than duplicated.
          example: "+15551234567"
        goals: { type: string, maxLength: 2000 }
        health_notes: { type: string, maxLength: 4000 }
        allergies: { type: string, maxLength: 1000 }
        dietary_restrictions: { type: string, maxLength: 1000 }
        food_dislikes: { type: string, maxLength: 1000 }
        medications: { type: string, maxLength: 2000 }
        height_in: { type: number, description: Height in inches. }
        weight_lbs: { type: number, description: Weight in pounds. }
        send_welcome:
          type: boolean
          default: true
          description: When false, you take delivery and receive `temp_password` and `intake_url`.
        send_intake:
          type: boolean
          default: true
          description: Whether to issue an intake form. Exactly one intake is minted either way.

    CreateClientResult:
      type: object
      required: [ client_id, email, full_name, welcome_email_sent, intake_url ]
      properties:
        client_id: { type: string }
        email: { type: string, format: email }
        full_name: { type: string }
        welcome_email_sent:
          type: [ boolean, 'null' ]
          description: Whether our welcome email sent. Null when `send_welcome` was false.
        intake_url:
          type: [ string, 'null' ]
          description: The intake link, when you took delivery. Null when we emailed it ourselves.
        temp_password:
          type: string
          description: |
            Returned when `send_welcome` was false, **or** when it was true and our send failed - so
            the account never exists with nobody holding a way to reach it.
        lead_link:
          type: object
          description: Present when this client was matched to an existing CRM lead.
          properties:
            kind: { type: string }
            message: { type: string }

    ClientCompliance:
      type: object
      required: [ client_id, window_days, since, compliance_pct, doses_logged, doses_expected, last_logged_on ]
      properties:
        client_id: { type: string }
        window_days:
          type: integer
          description: The window actually used, after clamping.
        since:
          type: string
          format: date
          description: The first day counted.
        compliance_pct:
          type: [ integer, 'null' ]
          description: |
            0–100, or **null** when the client logged nothing in the window. Null means "no signal",
            not "zero compliance" - an absent client does not trip a compliance automation.
        doses_logged: { type: integer }
        doses_expected: { type: integer }
        last_logged_on:
          type: [ string, 'null' ]
          format: date

    CheckInSummary:
      type: object
      required: [ id, submitted_at, reviewed, reviewed_at ]
      description: A check-in's metadata. Deliberately carries no answers and no score.
      properties:
        id: { type: string }
        submitted_at: { type: string, format: date-time }
        reviewed:
          type: boolean
          description: Whether the coach has reviewed this check-in.
        reviewed_at: { type: [ string, 'null' ], format: date-time }

    CheckInList:
      type: object
      required: [ client_id, check_ins, count ]
      properties:
        client_id: { type: string }
        check_ins:
          type: array
          items: { $ref: '#/components/schemas/CheckInSummary' }
        count:
          type: integer
          description: Rows in this response - not the client's lifetime total.

    DailyLogDates:
      type: object
      required: [ client_id, dates, count, last_logged_on ]
      properties:
        client_id: { type: string }
        dates:
          type: array
          description: Days with a log, newest first. No values, by design.
          items: { type: string, format: date }
        count: { type: integer }
        last_logged_on: { type: [ string, 'null' ], format: date }

    LoginLink:
      type: object
      required: [ client_id, login_url, expires_note ]
      properties:
        client_id: { type: string }
        login_url:
          type: string
          format: uri
          description: Signs its holder in as this client. Single use. Treat as a credential.
        expires_note:
          type: string
          description: |
            Stated rather than computed - the true lifetime is the project's auth link expiry, and
            reporting a precise timestamp we do not control would be a number that quietly goes wrong.
    CrmWriteError:
      type: object
      additionalProperties: false
      properties:
        error:
          type: string
        code:
          type: string
        ok:
          const: false
        bookingUrl:
          type: string
          description: Relative public booking path on this platform.
      required:
        - error
    CrmBookingOutcome:
      type: object
      additionalProperties: false
      properties:
        ok:
          const: true
        status:
          type: string
          enum:
            - booked
            - rescheduled
            - cancelled
        eventId:
          type: string
          format: uuid
        date:
          type: string
          format: date
          pattern: ^\d{4}-\d{2}-\d{2}$
        time:
          type: string
          pattern: ^([01]\d|2[0-3]):[0-5]\d$
        timezone:
          type: string
      required:
        - ok
        - status
        - eventId
