openapi: 3.1.0
info:
  title: Muovi Public API
  version: "1.0.0"
  summary: Public read-only access to Muovi's verified Argentine service professionals.
  description: |
    # Muovi Public `/v1` API

    Read-only HTTP API that lets LLMs (ChatGPT search, Perplexity, Claude,
    MCP-aware agents) and traditional clients discover Muovi's verified
    Argentine service professionals, their reviews, the service catalog,
    and the cities Muovi operates in.

    ## Base URL

    Production:

    ```
    https://muovi.com.ar/api/v1
    ```

    Spec URL (this document, served verbatim from the SPA's static `public/`
    directory by Vite/Vercel):

    ```
    https://muovi.com.ar/openapi.yaml
    ```

    ## Vercel routing (for connector reviewers)

    The public `/api/v1/*` namespace is **not** a separate service. It is a
    Vercel rewrite that maps to Muovi's Supabase Edge Functions under the
    `v1-*` naming convention:

    ```
    https://muovi.com.ar/api/v1/professionals
      → https://<project>.functions.supabase.co/v1-professionals
    https://muovi.com.ar/api/v1/professionals/{slug}
      → https://<project>.functions.supabase.co/v1-professionals/{slug}
    https://muovi.com.ar/api/v1/services
      → https://<project>.functions.supabase.co/v1-services
    https://muovi.com.ar/api/v1/cities
      → https://<project>.functions.supabase.co/v1-cities
    ```

    Connectors should always hit the stable `muovi.com.ar/api/v1` host —
    not the Supabase host directly — so we can swap backends without
    breaking integrations.

    ## Anti-leakage policy (non-negotiable)

    **Public payloads never include phone, whatsapp, or email.** Muovi is
    a trust-first, on-platform marketplace. Contact between consumers and
    professionals happens exclusively through the on-platform conversation
    flow reachable from each professional's `profile_url`. The following
    field shapes are explicitly forbidden in every response schema defined
    in this spec, and any client or downstream connector that adds them
    is violating Muovi's data-use terms:

      - `phone`, `phone_number`, `mobile`
      - `whatsapp`, `wa_link`, `wa.me` URLs
      - `email`, `contact_email`
      - `tel:` / `telephone` URIs
      - `contactPoint.telephone` (schema.org-style nested telephone)

    Every endpoint below has been audited against this list. The
    `x-anti-leakage` extension on `info` exists so generated docs surface
    the policy programmatically.

    ## Deep-link contract

    Connectors that want to drop the consumer back onto Muovi pre-filled
    with a task draft for a specific service can use the documented
    deep-link query parameters:

    ```
    https://muovi.com.ar/?create_task=1&service={service_slug}
    ```

    - `create_task=1` — opens the post-a-task flow on landing.
    - `service` — the service slug (matches `Service.slug` in this spec).

    This is the **only** supported off-platform pre-fill contract. Any
    additional query parameters are ignored.

    ## Authentication

    All endpoints in this spec are public and unauthenticated. Rate limits
    apply per source IP and (when present) per `X-Muovi-Connector` header.
    Exceeding the limit returns `429` with the standard error envelope.

    The optional `X-API-Key` header bumps a request to the `premium`
    tier (600 req/min, up from 60 req/min for `public`). Keys are
    issued manually by Muovi — see
    `supabase/functions/_shared/v1-ratelimit-README.md` for the request
    process. Every response (success or failure) carries
    `X-RateLimit-Limit` / `X-RateLimit-Remaining` / `X-RateLimit-Reset`
    headers so connectors can self-throttle without provoking a 429.

    ## Versioning

    The path prefix `/v1` is the contract version. Backwards-incompatible
    changes will ship under a new prefix (`/v2`) and the old prefix will
    remain available for a deprecation window of at least 90 days.
  x-anti-leakage: "Public payloads never include phone, whatsapp, or email."
  contact:
    name: Muovi API
    url: https://muovi.com.ar
  license:
    name: Proprietary — see https://muovi.com.ar/terms
    url: https://muovi.com.ar/terms
servers:
  - url: https://muovi.com.ar/api/v1
    description: Production
security: []
tags:
  - name: Professionals
    description: Search and detail endpoints for verified Argentine service professionals.
  - name: Reviews
    description: Paginated reviews for a given professional.
  - name: Catalog
    description: Static catalog endpoints (services, cities, neighborhoods).
paths:
  /professionals:
    get:
      tags: [Professionals]
      operationId: searchProfessionals
      summary: Search verified professionals
      description: |
        Returns a paginated list of verified Argentine service professionals
        matching the supplied filters. Ranking is Muovi-internal (Bayesian
        composite of platform + verified Google review signals + verification
        badges) and is **not** part of the public contract — order may
        change between calls. Use this endpoint for discovery; use
        `GET /professionals/{slug}` for the canonical detail payload.
      parameters:
        - name: service
          in: query
          required: false
          description: Service slug (matches `Service.slug`). E.g. `electricidad`.
          schema:
            type: string
            example: electricidad
        - name: city
          in: query
          required: false
          description: City slug (matches `City.slug`). E.g. `ar-bue-caba`.
          schema:
            type: string
            example: ar-bue-caba
        - name: neighborhood
          in: query
          required: false
          description: Neighborhood slug (matches `Neighborhood.slug`). E.g. `palermo`.
          schema:
            type: string
            example: palermo
        - name: verified_identity
          in: query
          required: false
          description: When `true`, only return pros whose identity has been verified by Muovi.
          schema:
            type: boolean
            example: true
        - name: has_matricula
          in: query
          required: false
          description: When `true`, only return pros with a verified professional `matricula` on file (e.g. electricians, gas fitters).
          schema:
            type: boolean
            example: true
        - name: min_rating
          in: query
          required: false
          description: Minimum blended average rating (0–5, inclusive).
          schema:
            type: number
            format: float
            minimum: 0
            maximum: 5
            example: 4.5
        - name: min_reviews
          in: query
          required: false
          description: Minimum blended review count.
          schema:
            type: integer
            minimum: 0
            example: 10
        - name: limit
          in: query
          required: false
          description: Maximum number of results per page.
          schema:
            type: integer
            minimum: 1
            maximum: 50
            default: 20
            example: 20
        - name: offset
          in: query
          required: false
          description: Zero-based offset into the result set for pagination.
          schema:
            type: integer
            minimum: 0
            default: 0
            example: 0
      responses:
        '200':
          description: Paginated list of matching professionals.
          content:
            application/json:
              schema:
                type: object
                required: [data, pagination]
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Professional'
                  pagination:
                    $ref: '#/components/schemas/Pagination'
              examples:
                electricians_caba:
                  summary: Electricians in CABA
                  value:
                    data:
                      - id: "8e3c5b41-6f2a-4f7e-8b1d-2c0a9d8f6c11"
                        slug: "juan-p-electricista-caba"
                        display_name: "Juan P."
                        headline: "Electricista matriculado · CABA"
                        city: { slug: "ar-bue-caba", name: "CABA" }
                        neighborhoods:
                          - { slug: "palermo", name: "Palermo" }
                          - { slug: "villa-crespo", name: "Villa Crespo" }
                        services:
                          - { slug: "electricidad", name: "Electricidad" }
                        rating: 4.8
                        review_count: 142
                        years_active: 12
                        verifications:
                          identity_verified: true
                          phone_verified: true
                          email_verified: true
                          matricula: { verified: true, type: "electricista_categoria_1" }
                          background_check: true
                          years_active: 12
                        profile_url: "https://muovi.com.ar/p/juan-p-electricista-caba"
                    pagination:
                      limit: 20
                      offset: 0
                      total: 1
                      has_more: false
        '400':
          $ref: '#/components/responses/BadRequest'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
  /professionals/{slug}:
    get:
      tags: [Professionals]
      operationId: getProfessional
      summary: Get a single professional by slug
      description: |
        Returns the canonical, full-detail public payload for one
        professional. Use the `profile_url` field to deep-link an end-user
        back to Muovi's on-platform conversation flow.

        ## Deep-link hint

        `profile_url` accepts the standard deep-link contract
        documented in `info.description`:

        ```
        {profile_url}?create_task=1&service={service_slug}
        ```

        Appending `?create_task=1&service={slug}` (where `slug` is a
        value from the `services[]` array in the response, or any slug
        from `GET /services`) pre-fills the on-platform task creation
        flow with the chosen service and auto-targets this professional.
        Connectors SHOULD use this form when recommending the pro for a
        specific service.
      parameters:
        - name: slug
          in: path
          required: true
          description: The professional's URL-safe slug.
          schema:
            type: string
            example: juan-p-electricista-caba
      responses:
        '200':
          description: The professional's public detail payload.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    $ref: '#/components/schemas/ProfessionalDetail'
              examples:
                juan_p:
                  summary: Example detail payload
                  value:
                    data:
                      id: "8e3c5b41-6f2a-4f7e-8b1d-2c0a9d8f6c11"
                      slug: "juan-p-electricista-caba"
                      display_name: "Juan P."
                      headline: "Electricista matriculado · CABA"
                      bio: "Electricista matriculado con 12 años de experiencia en instalaciones residenciales y comerciales."
                      avatar_url: "https://cdn.muovi.com.ar/avatars/juan-p.jpg"
                      portfolio:
                        - "https://cdn.muovi.com.ar/portfolio/juan-p-1.jpg"
                        - "https://cdn.muovi.com.ar/portfolio/juan-p-2.jpg"
                      city: { slug: "ar-bue-caba", name: "CABA" }
                      neighborhoods:
                        - { slug: "palermo", name: "Palermo" }
                      services:
                        - { slug: "electricidad", name: "Electricidad" }
                      specialties: ["Tableros", "Iluminación LED"]
                      rating: 4.8
                      review_count: 142
                      member_since: "2024-03-11T10:00:00Z"
                      verifications:
                        identity_verified: true
                        phone_verified: true
                        email_verified: true
                        matricula: { verified: true, type: "electricista_categoria_1" }
                        background_check: true
                        years_active: 12
                      profile_url: "https://muovi.com.ar/p/juan-p-electricista-caba"
        '400':
          $ref: '#/components/responses/BadRequest'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
  /professionals/{slug}/reviews:
    get:
      tags: [Reviews]
      operationId: listProfessionalReviews
      summary: List reviews for a professional
      description: |
        Paginated reviews left for the given professional. Reviews are sorted
        most-recent first. Each review's `author_name` is the reviewer's
        first name plus last initial only (e.g. "María G.") — full names are
        never returned.
      parameters:
        - name: slug
          in: path
          required: true
          description: The professional's URL-safe slug.
          schema:
            type: string
            example: juan-p-electricista-caba
        - name: limit
          in: query
          required: false
          description: Maximum number of reviews per page.
          schema:
            type: integer
            minimum: 1
            maximum: 50
            default: 20
            example: 20
        - name: offset
          in: query
          required: false
          description: Zero-based offset into the review list.
          schema:
            type: integer
            minimum: 0
            default: 0
            example: 0
      responses:
        '200':
          description: Paginated reviews.
          content:
            application/json:
              schema:
                type: object
                required: [data, pagination]
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Review'
                  pagination:
                    $ref: '#/components/schemas/Pagination'
              examples:
                juan_p_reviews:
                  summary: Two recent reviews
                  value:
                    data:
                      - id: "r-9981"
                        rating: 5
                        title: "Excelente trabajo"
                        comment: "Vino puntual, trabajó muy prolijo y dejó todo limpio."
                        author_name: "María G."
                        author_role: "client"
                        author_avatar_url: null
                        service_name: "Electricidad"
                        created_at: "2026-05-12T18:33:00Z"
                      - id: "r-9974"
                        rating: 4
                        title: "Muy bien"
                        comment: "Resolvió el problema rápido."
                        author_name: "Pedro L."
                        author_role: "client"
                        author_avatar_url: null
                        service_name: "Electricidad"
                        created_at: "2026-05-04T09:12:00Z"
                    pagination:
                      limit: 20
                      offset: 0
                      total: 2
                      has_more: false
        '400':
          $ref: '#/components/responses/BadRequest'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
  /services:
    get:
      tags: [Catalog]
      operationId: listServices
      summary: List the service catalog
      description: |
        Returns the full live service catalog Muovi supports. The `slug`
        field is the stable identifier used as the `service` query
        parameter on `/professionals` and as the `service` parameter on the
        deep-link contract documented in `info.description`.
      responses:
        '200':
          description: The service catalog.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Service'
              examples:
                catalog:
                  summary: Subset of the live catalog
                  value:
                    data:
                      - slug: "electricidad"
                        name: "Electricidad"
                        description: "Instalaciones, reparaciones y mantenimiento eléctrico."
                        requires_matricula: true
                      - slug: "plomeria"
                        name: "Plomería"
                        description: "Reparación de cañerías, instalaciones sanitarias."
                        requires_matricula: false
                      - slug: "pintura-nueva"
                        name: "Pintura"
                        description: "Pintura interior y exterior residencial y comercial."
                        requires_matricula: false
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
  /cities:
    get:
      tags: [Catalog]
      operationId: listCities
      summary: List the city catalog
      description: |
        Returns every city Muovi serves, with the list of active
        neighborhoods nested under each. The `slug` field is the stable
        identifier used as the `city` / `neighborhood` query parameters on
        `/professionals`.
      responses:
        '200':
          description: The city catalog.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/City'
              examples:
                catalog:
                  summary: Example cities payload
                  value:
                    data:
                      - slug: "ar-bue-caba"
                        name: "CABA"
                        region: "Buenos Aires"
                        neighborhoods:
                          - { slug: "palermo", name: "Palermo" }
                          - { slug: "villa-crespo", name: "Villa Crespo" }
                          - { slug: "belgrano", name: "Belgrano" }
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
components:
  schemas:
    PublicVerifications:
      type: object
      description: |
        Verification taxonomy for a professional. Shape is owned by MOB-129
        and mirrored here so the contract is self-contained. All fields are
        required so connectors can rely on a stable shape.
      required:
        - identity_verified
        - phone_verified
        - email_verified
        - matricula
        - background_check
        - years_active
      properties:
        identity_verified:
          type: boolean
          description: Muovi confirmed government-issued identity documents for this pro.
          example: true
        phone_verified:
          type: boolean
          description: Pro completed Muovi's phone-ownership challenge.
          example: true
        email_verified:
          type: boolean
          description: Pro confirmed ownership of their account email.
          example: true
        matricula:
          type: object
          description: Professional licence (matrícula) verification, if applicable to the trade.
          required: [verified, type]
          properties:
            verified:
              type: boolean
              example: true
            type:
              type:
                - string
                - "null"
              description: Matrícula category, or `null` if the trade does not require one.
              example: electricista_categoria_1
        background_check:
          type: boolean
          description: Muovi confirmed a clean criminal-background record for this pro.
          example: true
        years_active:
          type: number
          description: Years the pro has been active on Muovi (and on partner platforms, where verified).
          minimum: 0
          example: 12
    Professional:
      type: object
      description: |
        Search-result shape for a professional. Lighter than `ProfessionalDetail`.
        **Never** contains phone, whatsapp, or email — contact happens only
        via `profile_url`.
      required:
        - id
        - slug
        - display_name
        - city
        - neighborhoods
        - services
        - rating
        - review_count
        - verifications
        - profile_url
      properties:
        id:
          type: string
          format: uuid
          example: "8e3c5b41-6f2a-4f7e-8b1d-2c0a9d8f6c11"
        slug:
          type: string
          description: URL-safe identifier for the professional.
          example: juan-p-electricista-caba
        display_name:
          type: string
          description: First name plus last initial (e.g. "Juan P."). Full surnames are never returned.
          example: "Juan P."
        headline:
          type:
            - string
            - "null"
          description: Short positioning line shown next to the pro in lists.
          example: "Electricista matriculado · CABA"
        avatar_url:
          type:
            - string
            - "null"
          format: uri
          example: "https://cdn.muovi.com.ar/avatars/juan-p.jpg"
        city:
          $ref: '#/components/schemas/City'
        neighborhoods:
          type: array
          items:
            $ref: '#/components/schemas/Neighborhood'
        services:
          type: array
          items:
            $ref: '#/components/schemas/Service'
        rating:
          type: number
          minimum: 0
          maximum: 5
          description: Blended average rating across Muovi and verified Google reviews.
          example: 4.8
        review_count:
          type: integer
          minimum: 0
          description: Blended review count across Muovi and verified Google reviews.
          example: 142
        years_active:
          type:
            - number
            - "null"
          description: Convenience copy of `verifications.years_active` for sorting in search.
          example: 12
        verifications:
          $ref: '#/components/schemas/PublicVerifications'
        profile_url:
          type: string
          format: uri
          description: |
            Canonical on-platform URL for the professional. **The only sanctioned
            contact channel.** Public clients must drive end-users here rather
            than scraping or synthesising any off-platform contact handle.
          example: "https://muovi.com.ar/p/juan-p-electricista-caba"
    ProfessionalDetail:
      allOf:
        - $ref: '#/components/schemas/Professional'
        - type: object
          description: |
            Detail view — adds `bio`, `portfolio`, `specialties`, and
            `member_since` on top of the search-result shape.
          required:
            - bio
            - portfolio
            - specialties
            - member_since
          properties:
            bio:
              type:
                - string
                - "null"
              description: Free-form pro bio, sanitised server-side to strip phone / email / DNI patterns.
              example: "Electricista matriculado con 12 años de experiencia en instalaciones residenciales y comerciales."
            portfolio:
              type: array
              items:
                type: string
                format: uri
              description: Up to 6 portfolio image URLs.
              example:
                - "https://cdn.muovi.com.ar/portfolio/juan-p-1.jpg"
                - "https://cdn.muovi.com.ar/portfolio/juan-p-2.jpg"
            specialties:
              type: array
              items:
                type: string
              maxItems: 8
              description: Free-form specialty tags (sanitised server-side).
              example: ["Tableros", "Iluminación LED"]
            member_since:
              type: string
              format: date-time
              example: "2024-03-11T10:00:00Z"
    Review:
      type: object
      description: |
        A single review of a professional. Author identity is reduced to
        "First L." form before serialization — full names, contact handles,
        and reviewer free text containing recognisable contact patterns are
        all stripped server-side.
      required:
        - id
        - rating
        - author_name
        - author_role
        - created_at
      properties:
        id:
          type: string
          example: "r-9981"
        rating:
          type: integer
          minimum: 1
          maximum: 5
          example: 5
        title:
          type:
            - string
            - "null"
          example: "Excelente trabajo"
        comment:
          type:
            - string
            - "null"
          example: "Vino puntual, trabajó muy prolijo y dejó todo limpio."
        author_name:
          type: string
          description: First name plus last initial (e.g. "María G.").
          example: "María G."
        author_role:
          type: string
          enum: [client, worker]
          example: client
        author_avatar_url:
          type:
            - string
            - "null"
          format: uri
          example: null
        service_name:
          type:
            - string
            - "null"
          description: Display name of the service category this review is associated with.
          example: "Electricidad"
        created_at:
          type: string
          format: date-time
          example: "2026-05-12T18:33:00Z"
    Service:
      type: object
      description: A service category in the Muovi catalog.
      required: [slug, name]
      properties:
        slug:
          type: string
          description: Stable slug. Used as the `service` query parameter and in the deep-link contract.
          example: electricidad
        name:
          type: string
          example: Electricidad
        description:
          type:
            - string
            - "null"
          example: "Instalaciones, reparaciones y mantenimiento eléctrico."
        requires_matricula:
          type: boolean
          description: When `true`, professionals listed under this service must hold a verified matrícula.
          example: true
    Neighborhood:
      type: object
      description: A neighborhood inside a city.
      required: [slug, name]
      properties:
        slug:
          type: string
          example: palermo
        name:
          type: string
          example: Palermo
    City:
      type: object
      description: A city Muovi serves.
      required: [slug, name]
      properties:
        slug:
          type: string
          example: ar-bue-caba
        name:
          type: string
          example: CABA
        region:
          type:
            - string
            - "null"
          description: Region / province name.
          example: "Buenos Aires"
        neighborhoods:
          type: array
          description: Active neighborhoods inside this city.
          items:
            $ref: '#/components/schemas/Neighborhood'
    Pagination:
      type: object
      description: Standard pagination envelope.
      required: [limit, offset, total, has_more]
      properties:
        limit:
          type: integer
          minimum: 1
          maximum: 50
          example: 20
        offset:
          type: integer
          minimum: 0
          example: 0
        total:
          type: integer
          minimum: 0
          description: Total matching records across all pages.
          example: 142
        has_more:
          type: boolean
          description: True when another page exists past `offset + limit`.
          example: true
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              example: "rate_limited"
            message:
              type: string
              example: "Too many requests. Try again in 60 seconds."
            details:
              type: object
              additionalProperties: true
  responses:
    BadRequest:
      description: Invalid query parameter or path parameter.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            invalid_limit:
              summary: limit exceeds 50
              value:
                error:
                  code: "invalid_parameter"
                  message: "limit must be between 1 and 50"
                  details: { parameter: "limit", value: 100 }
    NotFound:
      description: The requested resource does not exist.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            no_such_pro:
              summary: Unknown professional slug
              value:
                error:
                  code: "not_found"
                  message: "No professional with slug 'unknown-slug'"
    RateLimited:
      description: |
        Too many requests from this caller. The response carries
        `Retry-After` (seconds) plus the standard `X-RateLimit-*`
        counters. Every other `/v1` response also carries the
        `X-RateLimit-*` counters so connectors can self-throttle.
      headers:
        Retry-After:
          description: Seconds to wait before retrying. Always ≥ 1.
          schema: { type: integer, minimum: 1, example: 47 }
        X-RateLimit-Limit:
          description: The request ceiling for the current tier.
          schema: { type: integer, example: 60 }
        X-RateLimit-Remaining:
          description: Remaining requests in the current window. `0` on 429.
          schema: { type: integer, example: 0 }
        X-RateLimit-Reset:
          description: Unix-timestamp seconds at which the window resets.
          schema: { type: integer, example: 1700000060 }
        X-RateLimit-Tier:
          description: Either `public` or `premium` (requires `X-API-Key`).
          schema: { type: string, enum: [public, premium], example: public }
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            rate_limited:
              summary: Per-IP limit exceeded
              value:
                error:
                  code: "rate_limited"
                  message: "Too many requests. Try again in 60 seconds."
                  details: { retry_after_seconds: 60 }
    InternalError:
      description: Unexpected server error.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            internal:
              summary: Generic server error
              value:
                error:
                  code: "internal_error"
                  message: "An unexpected error occurred. Please retry."
