openapi: 3.1.0

info:
  title: Clarflow Integrations API
  version: '1.0.0'
  summary: Listen-only API for forwarding Clarflow quiz completions to automation platforms.
  description: |
    The Clarflow Integrations API lets an automation platform (Zapier, Make, or a
    plain webhook consumer) subscribe to quiz completions on a Clarflow funnel and
    receive each completion as an HTTP POST.

    ## Authentication

    Every endpoint except `POST /api/integrations/session` is authenticated with a
    **bearer session token**:

    ```
    Authorization: Bearer <session token>
    ```

    You obtain a token by exchanging a Clarflow email and password once via
    `POST /api/integrations/session`. Clarflow never stores the password — the
    token is a signed, self-contained credential.

    The token carries a single scope, `integrations:listen`. It can read funnels
    and manage webhook subscriptions for one workspace. It **cannot** create API
    keys, modify funnels, change billing, or alter the account in any way.

    Tokens are valid for **90 days**. They are bound to a fingerprint of the
    user's current password, so changing the Clarflow password immediately
    revokes every outstanding token. When a call returns `401`, re-run the
    sign-in request to mint a new token.

    ## How the subscription flow works

    1. Sign in with email and password to get a token.
    2. `GET /api/zapier/funnels` to let the user pick a funnel.
    3. `POST /api/zapier/subscriptions` with the funnel id and your platform's
       webhook URL. Store the returned `id`.
    4. Clarflow POSTs the payload to your webhook URL on every completion.
    5. `DELETE /api/zapier/subscriptions/{id}` when the automation is turned off.

    The payload delivered to your webhook is described under
    [Webhook payload](#tag/Webhook-payload).

    ## Path naming

    The paths are under `/api/zapier/*` for historical reasons. They are
    **platform-neutral** and shared by the Zapier app, the Make app, and generic
    webhook consumers. Only the host of the registered webhook URL differs.

    ## Errors

    Errors return the appropriate status code with a JSON body of the form
    `{ "error": "human-readable message" }`.

  contact:
    name: Clarflow Support
    url: https://www.clarflow.com/docs/integrations-api
    email: support@clarflow.com
  license:
    name: Proprietary
    url: https://www.clarflow.com/terms

servers:
  - url: https://www.clarflow.com
    description: Production

tags:
  - name: Authentication
    description: Exchanging Clarflow credentials for a listen-only session token.
  - name: Account
    description: Identifying the connected workspace.
  - name: Funnels
    description: Listing funnels and fetching sample completion data.
  - name: Subscriptions
    description: Registering and removing webhook subscriptions.
  - name: Webhook payload
    description: |
      The JSON body Clarflow POSTs to your registered webhook URL each time a
      visitor completes a subscribed funnel. This is not an endpoint you call —
      it documents what Clarflow sends to you.

security:
  - sessionToken: []

paths:
  /api/integrations/session:
    post:
      tags: [Authentication]
      summary: Sign in and mint a session token
      operationId: createSession
      security: []
      description: |
        Exchanges a Clarflow email and password for a listen-only session token
        scoped to the user's personal workspace.

        Accounts that sign in with Google have no password and cannot use this
        endpoint; they return `401` like any other failed sign-in.

        Every failure mode — unknown email, Google-only account, wrong password —
        returns the same generic `401` so the response cannot be used to
        enumerate registered email addresses.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, password]
              properties:
                email:
                  type: string
                  format: email
                  description: The Clarflow account email.
                  examples: ['jane@example.com']
                password:
                  type: string
                  format: password
                  description: The Clarflow account password.
      responses:
        '200':
          description: Authenticated. Store `token` and send it as a bearer token.
          content:
            application/json:
              schema:
                type: object
                required: [token, workspaceId, workspaceName]
                properties:
                  token:
                    type: string
                    description: |
                      The session token. Valid for 90 days, or until the account
                      password changes.
                  workspaceId:
                    type: string
                    format: uuid
                    description: The workspace this token is scoped to.
                  workspaceName:
                    type: string
                    description: |
                      Display name for the workspace. Falls back to
                      "Clarflow workspace" when the workspace is unnamed.
                    examples: ['Acme Supplements']
        '400':
          description: Email or password missing from the request body.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                missing:
                  value: { error: 'Email and password are required' }
        '401':
          description: |
            Invalid credentials. Returned for an unknown email, a Google-only
            account, or a wrong password — deliberately indistinguishable.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                invalid:
                  value: { error: 'Invalid email or password' }
        '404':
          description: The account authenticated but has no workspace.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                noWorkspace:
                  value: { error: 'No workspace found for this account' }
        '500':
          $ref: '#/components/responses/InternalError'

  /api/zapier/me:
    get:
      tags: [Account]
      summary: Test the connection
      operationId: getMe
      description: |
        Validates the session token and returns the workspace it is scoped to.
        Use this as the connection test — it is the cheapest call that proves a
        token is still valid.
      responses:
        '200':
          description: The token is valid.
          content:
            application/json:
              schema:
                type: object
                required: [workspaceId, workspaceName]
                properties:
                  workspaceId:
                    type: string
                    format: uuid
                  workspaceName:
                    type: string
                    examples: ['Acme Supplements']
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/InternalError'

  /api/zapier/funnels:
    get:
      tags: [Funnels]
      summary: List the workspace's funnels
      operationId: listFunnels
      description: |
        Returns every funnel in the connected workspace. Intended to populate a
        funnel picker before creating a subscription.
      responses:
        '200':
          description: The workspace's funnels.
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  required: [id, name]
                  properties:
                    id:
                      type: string
                      format: uuid
                      description: Pass this as `funnelId` when subscribing.
                    name:
                      type: string
                      description: The funnel's title, for display.
                      examples: ['Skincare Finder Quiz']
              examples:
                twoFunnels:
                  value:
                    - id: 'f1e2d3c4-b5a6-4789-9012-3456789abcde'
                      name: 'Skincare Finder Quiz'
                    - id: '0fedcba9-8765-4321-a098-765432100000'
                      name: 'Protein Powder Match'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/InternalError'

  /api/zapier/responses:
    get:
      tags: [Funnels]
      summary: Fetch sample completion data
      operationId: listSampleResponses
      description: |
        Returns a single representative completion payload for the given funnel,
        so users can map fields before any real completion has occurred.

        The sample is produced by the same builder as live events, so its shape
        is guaranteed to match what your webhook will actually receive. The
        values are fictional.
      parameters:
        - name: funnelId
          in: query
          required: true
          description: The funnel to build a sample for.
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: |
            An array containing exactly one sample payload. The array wrapper
            matches the polling convention automation platforms expect.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/CompletionPayload' }
        '400':
          description: The `funnelId` query parameter was missing.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                missing:
                  value: { error: 'funnelId is required' }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/FunnelNotFound'
        '500':
          $ref: '#/components/responses/InternalError'

  /api/zapier/subscriptions:
    post:
      tags: [Subscriptions]
      summary: Subscribe a webhook to a funnel
      operationId: createSubscription
      description: |
        Registers a webhook URL to receive every completion of the given funnel.
        Call this when the user turns their automation on.

        Store the returned `id` — it is required to unsubscribe.

        Only URLs hosted by a supported automation platform are accepted. This is
        a deliberate restriction: Clarflow only ever forwards to URLs registered
        here, never to a URL supplied at delivery time, so the delivery endpoint
        cannot be abused as an open forwarding proxy.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [funnelId]
              properties:
                funnelId:
                  type: string
                  format: uuid
                  description: The funnel to watch.
                hookUrl:
                  type: string
                  format: uri
                  description: |
                    The webhook URL to deliver completions to. Must be hosted at
                    `hooks.zapier.com` or `hook.<region>.make.com`.
                  examples: ['https://hooks.zapier.com/hooks/standard/123456/abcdef/']
                targetUrl:
                  type: string
                  format: uri
                  description: |
                    Accepted as an alias for `hookUrl`, for platforms that send
                    the destination under this name. Supply one or the other.
              examples:
                - funnelId: 'f1e2d3c4-b5a6-4789-9012-3456789abcde'
                  hookUrl: 'https://hooks.zapier.com/hooks/standard/123456/abcdef/'
      responses:
        '201':
          description: Subscription created.
          content:
            application/json:
              schema:
                type: object
                required: [id]
                properties:
                  id:
                    type: string
                    format: uuid
                    description: Pass to `DELETE /api/zapier/subscriptions/{id}`.
        '400':
          description: |
            A required field was missing, or the webhook URL is not hosted by a
            supported platform.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                missingFields:
                  value: { error: 'funnelId and hookUrl are required' }
                unsupportedHost:
                  value:
                    error: 'hookUrl must be a Zapier (hooks.zapier.com) or Make (hook.*.make.com) webhook URL'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/FunnelNotFound'
        '500':
          $ref: '#/components/responses/InternalError'

  /api/zapier/subscriptions/{id}:
    delete:
      tags: [Subscriptions]
      summary: Remove a webhook subscription
      operationId: deleteSubscription
      description: |
        Stops delivery for a subscription. Call this when the user turns their
        automation off.

        **Idempotent** — deleting a subscription that does not exist, or one
        belonging to another workspace, still returns `200`. Teardown therefore
        never fails, and repeated calls are safe.
      parameters:
        - name: id
          in: path
          required: true
          description: The subscription id returned when subscribing.
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: The subscription is gone (or never existed).
          content:
            application/json:
              schema:
                type: object
                required: [success]
                properties:
                  success:
                    type: boolean
                    const: true
        '400':
          description: No subscription id was supplied in the path.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                missingId:
                  value: { error: 'Subscription id is required' }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/InternalError'

  /your-webhook-url:
    post:
      tags: [Webhook payload]
      summary: 'Delivered by Clarflow: a quiz completion'
      operationId: completionWebhook
      security: []
      description: |
        **You do not call this endpoint — Clarflow calls yours.** It is documented
        here so you can see exactly what arrives at the URL you registered.

        Clarflow POSTs this JSON body to every webhook subscribed to a funnel
        each time a visitor completes it. Delivery is fire-and-forget with a
        5-second timeout per webhook: a slow or failing endpoint never blocks the
        visitor's experience, and one failing webhook never affects the others.

        Answers appear twice, deliberately. They are flattened to the **top level**
        so each question becomes a first-class, mappable field, and they are
        repeated in full structural detail under `_responses` for consumers that
        need step ids and option ids.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CompletionPayload' }
      responses:
        '200':
          description: |
            Return any 2xx to acknowledge. Clarflow does not currently retry
            failed deliveries, and the response body is ignored.

components:
  securitySchemes:
    sessionToken:
      type: http
      scheme: bearer
      description: |
        The session token returned by `POST /api/integrations/session`, sent as
        `Authorization: Bearer <token>`. Scope is `integrations:listen`.

  responses:
    Unauthorized:
      description: |
        The token is missing, malformed, expired, of the wrong scope, or was
        invalidated by a password change. Mint a new one by signing in again.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            invalidToken:
              value: { error: 'Invalid or missing session token' }

    FunnelNotFound:
      description: |
        No funnel with that id exists in the connected workspace. Also returned
        when the funnel exists but belongs to a different workspace, so the
        response cannot be used to probe for funnels you cannot access.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            notFound:
              value: { error: 'Funnel not found in this workspace' }

    InternalError:
      description: Unexpected server error.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            serverError:
              value: { error: 'Internal server error' }

  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: string
          description: A human-readable description of what went wrong.

    CompletionPayload:
      type: object
      description: |
        A single quiz completion.

        Alongside the two reserved keys below, the top level carries one
        **additional property per answer**, named after the author-defined
        variable name where one exists and after a sanitised version of the
        question or input label otherwise. Key names are lowercased with
        non-alphanumeric characters collapsed to underscores and truncated to 50
        characters. Colliding names get a numeric suffix (`goal`, `goal_2`) so no
        answer is ever silently dropped, and leading underscores are stripped so
        an answer can never shadow `_meta` or `_responses`.

        Because these keys are derived from the funnel's own content, they differ
        from funnel to funnel. Fetch a sample from `GET /api/zapier/responses` to
        discover the keys for a specific funnel.

        Multi-select answers are joined into one comma-separated string.
        **Password inputs are never forwarded.**
      required: [_meta, _responses]
      properties:
        _meta:
          type: object
          description: Metadata about the completion.
          required: [funnelId, funnelTitle, sessionId, submittedAt, email, phone]
          properties:
            funnelId:
              type: string
              format: uuid
            funnelTitle:
              type: string
              examples: ['Skincare Finder Quiz']
            sessionId:
              type: [string, 'null']
              description: Identifier for the visitor's session, when available.
            submittedAt:
              type: string
              format: date-time
              description: ISO-8601 timestamp of the completion.
            email:
              type: [string, 'null']
              description: The visitor's email, when the funnel collected one.
            phone:
              type: [string, 'null']
              description: The visitor's phone number, when the funnel collected one.
        _responses:
          $ref: '#/components/schemas/StructuredResponses'
      additionalProperties:
        description: |
          One entry per answer — see the description above. Values are strings in
          practice; the type is left open for forward compatibility.
      examples:
        - primary_goal: 'Lose weight'
          email: 'jane@example.com'
          country: 'United States'
          _meta:
            funnelId: 'f1e2d3c4-b5a6-4789-9012-3456789abcde'
            funnelTitle: 'Skincare Finder Quiz'
            sessionId: 'sample-session-0001'
            submittedAt: '2026-01-01T00:00:00.000Z'
            email: 'jane@example.com'
            phone: '+15551234567'
          _responses:
            questions:
              - key: 'step-1_0'
                stepId: 'step-1'
                questionText: 'What is your primary goal?'
                selectedOptions: ['opt-1']
                selectedLabels: ['Lose weight']
            inputs:
              - key: 'step-2_0'
                stepId: 'step-2'
                label: 'Email'
                value: 'jane@example.com'
                inputType: 'email'
            dropdowns:
              - key: 'step-3_0'
                stepId: 'step-3'
                label: 'Country'
                value: 'United States'
                optionId: 'us'

    StructuredResponses:
      type: object
      description: |
        Every answer in full structural detail, grouped by element type. Use this
        when you need step or option identifiers rather than display labels.
      required: [questions, inputs, dropdowns]
      properties:
        questions:
          type: array
          description: Multiple-choice answers.
          items:
            type: object
            required: [key, stepId, questionText, selectedOptions, selectedLabels]
            properties:
              key:
                type: string
                description: 'Unique key for the element, formatted `{stepId}_{elementIndex}`.'
                examples: ['step-1_0']
              stepId:
                type: string
              title:
                type: string
                description: The element's author-facing title, when set.
              questionText:
                type: string
                description: The question as shown to the visitor.
              selectedOptions:
                type: array
                description: Ids of the chosen options.
                items: { type: string }
              selectedLabels:
                type: array
                description: Display labels of the chosen options.
                items: { type: string }
        inputs:
          type: array
          description: Free-text answers. Password inputs are excluded.
          items:
            type: object
            required: [key, stepId, label, value, inputType]
            properties:
              key: { type: string, examples: ['step-2_0'] }
              stepId: { type: string }
              label: { type: string }
              value: { type: string }
              inputType:
                type: string
                description: 'The input''s type, e.g. `email`, `text`, `tel`.'
                examples: ['email']
        dropdowns:
          type: array
          description: Dropdown selections.
          items:
            type: object
            required: [key, stepId, label, value, optionId]
            properties:
              key: { type: string, examples: ['step-3_0'] }
              stepId: { type: string }
              label: { type: string }
              value:
                type: string
                description: The selected option's display label.
              optionId:
                type: string
                description: The selected option's identifier.
