openapi: 3.1.0
info:
  title: MoveBlueprint Partner API
  version: "1.0.0-pilot"
  description: >
    Private partner API for moving companies. Connect your own website form, CRM,
    no-code tools (Zapier/Make), or AI voice agent to MoveBlueprint to create leads,
    deliver the AI video survey, read lead status, and (private beta) collect a
    date-holding deposit. All requests are scoped to the partner that owns the API key.

    Pilot note: endpoints tagged `beta` are built but gated; contact us to enable them.
    The pilot runs on the raw Supabase Functions host; logical paths like `POST /v1/leads`
    map to the function slugs below (a pretty `api.moveblueprint.com` host is planned).
servers:
  - url: https://dkieategdogjcrtqhkbv.supabase.co/functions/v1
    description: Pilot (raw functions host)

security:
  - bearerAuth: []

tags:
  - name: leads
    description: Create, list, and read leads
  - name: survey
    description: AI video-survey capture links
  - name: money
    description: Acceptance + deposits (private beta)

paths:
  /v1-leads:
    post:
      tags: [leads]
      operationId: createLead
      summary: Create a lead (logical POST /v1/leads)
      description: >
        Provide at least one of customer_name, email, phone. If from_address,
        to_address, home_size AND email are all present, an instant estimate is
        computed and returned. A lead is always created.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateLeadRequest'
      responses:
        '200':
          description: Lead created
          headers:
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CreateLeadResponse' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '502': { $ref: '#/components/responses/UpstreamError' }
  /v1-leads-list:
    get:
      tags: [leads]
      operationId: listLeads
      summary: List leads, keyset-paginated + delta sync (logical GET /v1/leads)
      description: >
        Page forward through leads ordered by (updated_at, id) ascending. Use
        updated_since for delta sync ("what changed since X") and pass next_cursor
        back verbatim to get the next page.
      parameters:
        - name: limit
          in: query
          schema: { type: integer, default: 25, minimum: 1, maximum: 100 }
        - name: updated_since
          in: query
          description: ISO 8601 timestamp; only leads with updated_at >= this.
          schema: { type: string, format: date-time }
        - name: cursor
          in: query
          description: Opaque cursor from a previous response's next_cursor.
          schema: { type: string }
      responses:
        '200':
          description: A page of lead summaries
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LeadListResponse' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /v1-lead-get/{lead_id}:
    get:
      tags: [leads]
      operationId: getLead
      summary: Get one lead's full status (logical GET /v1/leads/{id})
      parameters:
        - $ref: '#/components/parameters/LeadId'
      responses:
        '200':
          description: Lead detail + lifecycle
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LeadDetail' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
  /v1-lead-survey/{lead_id}:
    post:
      tags: [survey]
      operationId: getSurveyLink
      summary: Get the AI video-survey capture link (logical POST /v1/leads/{id}/video-survey)
      description: >
        Returns a hosted capture_url to send your customer. delivery:"link" means
        MoveBlueprint sends nothing; you deliver the URL on your own channel.
      parameters:
        - $ref: '#/components/parameters/LeadId'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                delivery: { type: string, enum: [link], default: link }
      responses:
        '200':
          description: Capture link
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SurveyLinkResponse' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
  /v1-survey-video-access/{survey_id}:
    post:
      tags: [survey]
      operationId: createSurveyVideoAccess
      summary: Create a forwardable video link (logical POST /v1/surveys/{id}/video-access)
      description: >
        Mints a link to the walkthrough video that opens with no MoveBlueprint account,
        so it can be handed to the carrier hauling the shipment. The link expires, can be
        revoked, and every open is recorded. Takes a survey_id, not a lead_id.
        The URL is returned ONCE; only a hash of it is stored. Requires the surveys:read
        scope, which keys issued before this endpoint existed may not carry.
        A link is limited to 20 opens per hour and answers 429 past that. Revoking stops
        new opens at once, but a video address already handed out keeps playing for up to
        ten minutes, so revoke is not a kill switch.
      parameters:
        - $ref: '#/components/parameters/SurveyId'
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                ttl_hours: { type: number, default: 72, maximum: 168, description: Values above 168 are capped at 168. }
                label: { type: string, maxLength: 120, description: Your own note for the audit trail. }
      responses:
        '200':
          description: Link created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SurveyVideoAccessResponse' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
    delete:
      tags: [survey]
      operationId: revokeSurveyVideoAccess
      summary: Revoke every video link on a survey (logical DELETE /v1/surveys/{id}/video-access)
      description: >
        Closes every live link on that survey at once. Links cannot be revoked one at a
        time by design. Safe to call twice; grants_revoked of 0 means there was nothing
        left to close. A video address someone already loaded keeps playing for up to ten
        minutes after this returns.
      parameters:
        - $ref: '#/components/parameters/SurveyId'
      responses:
        '200':
          description: Links revoked
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SurveyVideoRevokeResponse' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
  /v1-accept/{lead_id}:
    post:
      tags: [money]
      operationId: acceptQuote
      summary: "[beta] Record acceptance, non-binding terms OR binding e-sign"
      description: >
        Private beta. Non-binding mode records a terms+timestamp+IP acceptance.
        Binding mode (estimate_type binding_*) requires signature fields and records
        ESIGN/UETA evidence. Never books; booking happens after a paid deposit.
      parameters:
        - $ref: '#/components/parameters/LeadId'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: '#/components/schemas/AcceptRequest' }
      responses:
        '200':
          description: Acceptance recorded
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AcceptResponse' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
  /v1-deposits:
    post:
      tags: [money]
      operationId: createDeposit
      summary: "[beta] Create a deposit Stripe Checkout (logical POST /v1/deposits)"
      description: >
        Private beta. Creates a Stripe Checkout URL that settles to the PARTNER's
        connected account (merchant-of-record, 0% platform fee). Idempotent. Requires
        the partner to have carrier identity + a connected Stripe account.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateDepositRequest' }
      responses:
        '200':
          description: Checkout created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DepositResponse' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: Partner not eligible (carrier identity / connected account missing)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
  /v1-deposit-status/{deposit_id}:
    get:
      tags: [money]
      operationId: getDeposit
      summary: "[beta] Poll a deposit's status (logical GET /v1/deposits/{id})"
      parameters:
        - name: deposit_id
          in: path
          required: true
          schema: { type: string, format: uuid }
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '200':
          description: Deposit status
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DepositStatusResponse' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: >
        Partner API key as a bearer token, e.g. `Authorization: Bearer mbk_live_…`
        (or `mbk_test_…`). Create/revoke keys in the dashboard under Settings →
        Developers. Use server-side only, never expose the key in a browser.
  parameters:
    LeadId:
      name: lead_id
      in: path
      required: true
      schema: { type: string, format: uuid }
    SurveyId:
      name: survey_id
      in: path
      required: true
      description: Survey identifier, not a lead id.
      schema: { type: string, format: uuid }
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: Replaying the same key returns the original response (different body → 422).
      schema: { type: string }
  headers:
    RateLimitRemaining:
      description: Requests remaining in the current minute window.
      schema: { type: integer }
  schemas:
    CreateLeadRequest:
      type: object
      additionalProperties: false
      properties:
        customer_name: { type: string }
        name: { type: string, description: alias for customer_name }
        email: { type: string, format: email }
        phone: { type: string, description: E.164 preferred }
        from_address: { type: string }
        to_address: { type: string }
        home_size: { type: string, example: "2 bedroom" }
        move_date: { type: string, format: date }
        special_items: { type: array, items: { type: string } }
        source: { type: string, description: your channel tag, e.g. "website" }
      description: At least one of customer_name, email, phone is required.
    Estimate:
      type: object
      additionalProperties: false
      properties:
        min: { type: number, nullable: true }
        max: { type: number, nullable: true }
        currency: { type: string, example: usd }
    CreateLeadResponse:
      type: object
      properties:
        lead_id: { type: string, format: uuid }
        status: { $ref: '#/components/schemas/LeadLifecycle' }
        estimate:
          oneOf:
            - $ref: '#/components/schemas/Estimate'
            - type: 'null'
    LeadSummary:
      type: object
      properties:
        lead_id: { type: string, format: uuid }
        status: { $ref: '#/components/schemas/LeadLifecycle' }
        customer_name: { type: string, nullable: true }
        lead_source: { type: string, nullable: true }
        estimate:
          oneOf: [ { $ref: '#/components/schemas/Estimate' }, { type: 'null' } ]
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    LeadListResponse:
      type: object
      properties:
        data: { type: array, items: { $ref: '#/components/schemas/LeadSummary' } }
        next_cursor: { type: string, nullable: true }
        has_more: { type: boolean }
    LeadDetail:
      type: object
      properties:
        lead_id: { type: string, format: uuid }
        status: { $ref: '#/components/schemas/LeadLifecycle' }
        survey:
          type: object
          properties:
            survey_id: { type: string, nullable: true }
            status: { type: string, enum: [none, invited, recording, processing, inventory_ready, failed, unknown] }
        quote:
          type: object
          nullable: true
          properties:
            quote_id: { type: string }
            stage: { type: string, nullable: true }
            accepted_at: { type: string, format: date-time, nullable: true }
            signed_at: { type: string, format: date-time, nullable: true }
        deposit:
          type: object
          properties:
            status: { $ref: '#/components/schemas/PublicDepositStatus' }
        recommended_estimate:
          oneOf: [ { $ref: '#/components/schemas/Estimate' }, { type: 'null' } ]
    SurveyLinkResponse:
      type: object
      properties:
        survey_id: { type: string, nullable: true }
        capture_url: { type: string, format: uri }
        status: { type: string, example: awaiting_recording }
        delivery: { type: string, enum: [link] }
    SurveyVideoAccessResponse:
      type: object
      properties:
        survey_id: { type: string, format: uuid }
        grant_id: { type: string, format: uuid }
        view_url:
          type: string
          format: uri
          description: Shown ONCE. Only a hash is stored, so it cannot be recovered later.
        expires_at: { type: string, format: date-time }
        clip_count:
          type: integer
          description: Clips that can actually be served. Zero playable clips is a 422, not a link.
        forwardable: { type: boolean, example: true }
        note: { type: string }
    SurveyVideoRevokeResponse:
      type: object
      properties:
        survey_id: { type: string, format: uuid }
        revoked: { type: boolean, example: true }
        grants_revoked:
          type: integer
          description: Links that were still live. Zero is a success, not an error.
    AcceptRequest:
      type: object
      additionalProperties: false
      properties:
        accepter_email: { type: string, format: email }
        signer_name: { type: string, description: binding mode only }
        signer_email: { type: string, format: email, description: binding mode only }
        signature_image_b64: { type: string, description: binding mode only }
        liability_election: { type: string, description: binding mode only }
        esign_consent_checked: { type: boolean, description: binding mode only }
    AcceptResponse:
      type: object
      properties:
        status: { type: string, enum: [accepted, signed] }
        deposit_required: { type: boolean }
    CreateDepositRequest:
      type: object
      additionalProperties: false
      required: [lead_id, quote_share_link_id]
      properties:
        lead_id: { type: string, format: uuid }
        quote_share_link_id: { type: string, format: uuid }
        amount_cents: { type: integer, minimum: 100, maximum: 500000, description: defaults to the partner's configured deposit }
    DepositResponse:
      type: object
      properties:
        deposit_id: { type: string, format: uuid }
        checkout_url: { type: string, format: uri }
        amount_cents: { type: integer }
        status: { type: string, example: checkout_open }
    DepositStatusResponse:
      type: object
      properties:
        deposit_id: { type: string, format: uuid }
        status: { $ref: '#/components/schemas/PublicDepositStatus' }
        amount_cents: { type: integer, nullable: true }
        currency: { type: string }
        checkout_url: { type: string, nullable: true, description: present only while payable }
        paid_at: { type: string, format: date-time, nullable: true }
        lead_id: { type: string, format: uuid, nullable: true }
        created_at: { type: string, format: date-time, nullable: true }
    LeadLifecycle:
      type: string
      enum: [new, surveying, quoted, accepted, deposit_pending, booked, completed, lost, unknown]
    PublicDepositStatus:
      type: string
      enum: [none, pending, paid, failed, refunded, disputed, unknown]
    Error:
      type: object
      properties:
        error:
          type: object
          properties:
            code: { type: string }
            message: { type: string }
            details: { type: object, additionalProperties: true }
  responses:
    Unauthorized:
      description: Missing/invalid/revoked API key
      content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
    NotFound:
      description: Not found (or not owned by your key)
      content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
    Conflict:
      description: Conflicts with current state
      content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
    ValidationFailed:
      description: Request validation failed
      content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
    RateLimited:
      description: Rate limit exceeded (120/min per key)
      content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
    UpstreamError:
      description: An upstream step failed
      content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
