openapi: 3.1.0
info:
  title: Muovi Public API
  version: "1.0.0"
  summary: Public access to Muovi's verified Argentine service professionals, plus agent task-draft handovers.
  description: |
    # Muovi Public `/v1` API

    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.

    Every endpoint is read-only except `POST /task-handovers`, which saves a
    task draft and returns a link. The person who opens the link reviews the
    draft, signs in to Muovi and publishes it; the endpoint publishes nothing.

    ## Base URL

    Production:

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

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

    ```
    https://www.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://www.muovi.com.ar/api/v1/professionals
      → https://<project>.functions.supabase.co/v1-professionals
    https://www.muovi.com.ar/api/v1/professionals/{id}
      → https://<project>.functions.supabase.co/v1-professionals-slug/{id}
    https://www.muovi.com.ar/api/v1/services
      → https://<project>.functions.supabase.co/v1-services
    https://www.muovi.com.ar/api/v1/cities
      → https://<project>.functions.supabase.co/v1-cities
    ```

    Connectors should always hit the stable `www.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.

    ## Professional search: 5 results per page for one service in one barrio

    `GET /professionals` needs both `service` and `neighborhood`, and it
    answers 5 professionals per page, up to 3 pages (`page`). A request
    without both is answered `400`. Reviews rank the results; there is no
    minimum-rating or minimum-review filter. See the operation below.

    ## Deep-link contract

    Connectors that want to drop the consumer back onto Muovi to start a
    task with one professional link to Muovi's own task creation, naming
    the professional by the `id` the search returns:

    ```
    https://www.muovi.com.ar/post-task/v2?pro={professional_id}&vertical={service_slug}&source=assistant-link
    ```

    - `pro`: the professional's `id`. A value that is not an `id` from
      this API is ignored and the page opens an ordinary task.
    - `vertical`: optional, the service slug (matches `Service.slug`).
    - `source`: optional attribution token.

    This works for every professional the search returns. When the person
    publishes, the task goes to that professional first for 24 hours and
    then opens to everyone; on the form the person can switch on
    "Recibir ofertas de otros profesionales también", which publishes the
    task to everyone right away and still tells that professional.

    Links already shared in the older form keep working, for professionals
    who have a ProSite:

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

    To hand over a task the connector has already written, use
    `POST /task-handovers` instead: it returns a link that opens that draft.

    ## 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 ship under a new prefix (`/v2`), with the old prefix kept for a
    deprecation window of at least 90 days. One change was made in place
    under `/v1` in October 2026: `GET /professionals` now requires
    `service` and `neighborhood`, answers at most 5 results, no longer
    reads `min_rating` / `min_reviews`, and `slug` was removed from the
    professional objects (`id` identifies a professional, and
    `profile_url` is the public profile page). Later that month the
    optional `page` parameter (1 to 3) and `pagination.page` were added to
    `GET /professionals`, and its `pagination.has_more` can be true.
  x-anti-leakage: "Public payloads never include phone, whatsapp, or email."
  contact:
    name: Muovi API
    url: https://www.muovi.com.ar
  license:
    name: Proprietary — see https://www.muovi.com.ar/terms
    url: https://www.muovi.com.ar/terms
servers:
  - url: https://www.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).
  - name: Task handovers
    description: Save a task draft for a person to review, sign in and publish on Muovi.
paths:
  /professionals:
    get:
      tags: [Professionals]
      operationId: searchProfessionals
      summary: Find up to 5 professionals per page for one service in one barrio
      description: |
        Returns at most 5 professionals per page for one service in one
        barrio. Both `service` and `neighborhood` are required: a request
        without both is answered `400` with a message asking for them.

        `page` (1, 2 or 3; default 1) reads positions 1–5, 6–10 or 11–15 of
        the same order, so a search shows at most 15 professionals for one
        combination of filters. With a `limit` below 5, a page is `limit`
        wide (`limit=2&page=2` reads positions 3–4).
        `pagination.has_more` is true when a later page up to the third has
        at least one professional, and is false on page 3. A `page` above 3
        is answered `400` with a message suggesting the person publish a
        request so other professionals can send offers. No field states how
        many professionals match in total: `pagination.total` is the number
        returned on this page.

        Every professional returned has a coverage area that includes the
        barrio, and `match` in the response names the service, barrio and
        city searched. A result's `city` is that barrio's city, and a
        search result carries no `neighborhoods`: the professional's own
        base label is on `GET /professionals/{id}`.

        Results list professionals who declare the searched trade first,
        ordered by review score, with verified identity, background check
        and a matrícula or diploma counting toward it; professionals whose registered
        business is in another trade come last. The order is **not** part
        of the public contract. Professionals without reviews can appear.

        A `limit` above 5 is cut to 5. `offset` other than 0 is answered
        `400`: use `page`. `min_rating` and `min_reviews` are not read.

        Also answered `400`, with the parameter named in `error.details`: a
        `neighborhood` that is not an active barrio slug (of the given city,
        when `city` is passed), a `city` that is not a city slug, a barrio
        slug that exists in more than one city when `city` is absent, a
        `limit` that is not a positive integer, a `page` that is not an
        integer from 1 to 3, and a `verified_identity`
        or `has_matricula` other than `true`, `false`, `1` or `0`. A
        `service` slug Muovi does not list is answered `200` with an empty
        `data` list.

        Use `GET /professionals/{id}` for the detail payload.
      parameters:
        - name: service
          in: query
          required: true
          description: Service slug (matches `Service.slug`). E.g. `electricidad`.
          schema:
            type: string
            example: electricidad
        - name: neighborhood
          in: query
          required: true
          description: >-
            Barrio slug (matches `Neighborhood.slug` from `GET /cities`).
            E.g. `palermo`.
          schema:
            type: string
            example: palermo
        - name: city
          in: query
          required: false
          description: >-
            City slug (matches `City.slug`). E.g. `ar-bue-caba`. Needed only
            when a barrio slug exists in more than one city.
          schema:
            type: string
            example: ar-bue-caba
        - 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: page
          in: query
          required: false
          description: >-
            Page of `limit` results (5 by default): `1` (default), `2` or `3`. Request the next
            page only while `pagination.has_more` is true. Any other value
            is answered `400`.
          schema:
            type: integer
            minimum: 1
            maximum: 3
            default: 1
            example: 2
      responses:
        '200':
          description: Up to 5 matching professionals of the requested page.
          content:
            application/json:
              schema:
                type: object
                required: [data, pagination, match]
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Professional'
                  pagination:
                    $ref: '#/components/schemas/Pagination'
                  match:
                    $ref: '#/components/schemas/SearchMatch'
              examples:
                electricians_palermo:
                  summary: Electricians covering Palermo
                  value:
                    data:
                      - id: "8e3c5b41-6f2a-4f7e-8b1d-2c0a9d8f6c11"
                        display_name: "Juan P."
                        headline: "Electricista matriculado · CABA"
                        city: { slug: "ar-bue-caba", name: "CABA" }
                        services:
                          - { slug: "electricidad", name: "Electricidad" }
                        rating: 4.8
                        review_count: 142
                        verifications:
                          identity_verified: true
                          phone_verified: true
                          email_verified: true
                          matricula: { verified: true, type: "electricista_categoria_1" }
                          background_check: true
                        portfolio:
                          - "https://hyciaokddgtufwtptpcz.supabase.co/storage/v1/object/public/task-images/8e3c5b41-6f2a-4f7e-8b1d-2c0a9d8f6c11/portfolio/1771234567890-k3x9q7m2abc.jpg"
                        profile_url: "https://www.muovi.com.ar/profile/8e3c5b41-6f2a-4f7e-8b1d-2c0a9d8f6c11"
                    pagination:
                      limit: 5
                      offset: 0
                      page: 1
                      total: 1
                      has_more: false
                    match:
                      basis: coverage_area_includes_neighborhood
                      service: { slug: "electricidad", name: "Electricidad" }
                      neighborhood: { slug: "palermo", name: "Palermo" }
                      city: { slug: "ar-bue-caba", name: "CABA" }
        '400':
          $ref: '#/components/responses/BadRequest'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
  /professionals/{id}:
    get:
      tags: [Professionals]
      operationId: getProfessional
      summary: Get a single professional by id
      description: |
        Returns the full-detail public payload for one professional. Pass
        the `id` from `GET /professionals`. A ProSite slug from an older
        link is also accepted in the same path segment.

        `profile_url` is the professional's public profile page on Muovi,
        which has a button that starts a task for that professional. To
        link straight to that task creation, see the deep-link contract in
        `info.description`.
      parameters:
        - name: id
          in: path
          required: true
          description: The professional's `id` from `GET /professionals`, or a legacy ProSite slug.
          schema:
            type: string
            example: "8e3c5b41-6f2a-4f7e-8b1d-2c0a9d8f6c11"
      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"
                      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
                      profile_url: "https://www.muovi.com.ar/profile/8e3c5b41-6f2a-4f7e-8b1d-2c0a9d8f6c11"
        '400':
          $ref: '#/components/responses/BadRequest'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
  /professionals/{id}/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: id
          in: path
          required: true
          description: The professional's `id` from `GET /professionals`, or a legacy ProSite slug.
          schema:
            type: string
            example: "8e3c5b41-6f2a-4f7e-8b1d-2c0a9d8f6c11"
        - 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'
  /services/{service_slug}/requirements:
    get:
      tags: [Task handovers]
      operationId: getServiceRequirements
      summary: List the fields Muovi takes for one service
      description: |
        Returns the fields Muovi takes for a service, for a connector to
        read before calling `POST /task-handovers`. The list is projected
        from the intake specification the service runs: each slot's key, its
        question in Spanish, its type, the values a choice accepts, whether
        it is required, and the `ask_if` condition when it only applies
        after another answer.

        A trade's own job questions are listed as optional. Collect every
        required slot, asking the person one question per message and
        waiting for the answer before asking the next. A draft whose
        `urgency` is `flexible` is complete without the date. Do not ask
        the optional slots as a list; when what the person has told you, at
        any point in the conversation, gives an optional slot's value, send
        it in `slots` as well as writing it in `description`. Also ask what a professional would need to quote
        this specific job, skip anything the person already said, and write
        those details into `description`; no list of job questions is
        prescribed.

        Send the answers back keyed by slot `key` in the `slots` field of
        `POST /task-handovers`. The person confirms the address on Muovi's
        map after opening the draft link.

        A service slug alias (`plomero`) answers with its trade's fields.
        The `service_slug` in the answer is the slug `GET /services` lists,
        which for an alias may be the canonical one; send that slug to
        `POST /task-handovers`.
        This endpoint answers `404` while task handovers are switched off.
      parameters:
        - name: service_slug
          in: path
          required: true
          description: A service slug from `GET /services`.
          schema:
            type: string
            example: plomeria
      responses:
        '200':
          description: The fields for the service.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    $ref: '#/components/schemas/ServiceRequirements'
        '404':
          description: The slug is not a listed service, or task handovers are switched off.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          description: The service's questions could not be composed. Try again later.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /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'
  /task-handovers:
    post:
      tags: [Task handovers]
      operationId: createTaskHandover
      summary: Save a task draft and get a link for the person to publish it
      description: |
        Saves a task draft and returns a link to it. The person opens the
        link, reviews the draft, signs in to Muovi and publishes it
        themselves; this endpoint publishes nothing and notifies no
        professional.

        - A link nobody has opened expires 24 hours after it is created. The
          first person to open it takes the draft, signed in or not, and can
          reopen the link for up to 7 days; anyone else is refused.
        - A draft that carries every required answer opens on a review form
          with those answers filled in, unless Muovi's assistant has a
          question of its own for the person, and the person confirms the
          address on Muovi's map after opening the link, before publishing.
          A draft with only a description opens Muovi's assistant, which
          asks the person its questions.
        - With `professional_id` (the `id` of any professional from
          `GET /professionals`), publishing the draft sends the task to that
          professional first for 24 hours, when they are still listed on
          Muovi, and then opens it to everyone. With `open_to_others: true`
          the task opens to everyone right away and that professional is
          still told. The person can change that choice on the review form.
          `professional_slug` (a ProSite slug) is still accepted in place of
          `professional_id`; send one or the other. With neither, the draft
          is a general request.
        - `description`, `zone_text`, `title`, an `address` and every text
          answer in `slots` pass through Muovi's contact-detail filter before
          they are stored.
        - To hand over the full request, read
          `GET /services/{service_slug}/requirements` and collect the
          required fields it lists, asking the person one question per
          message and waiting for the answer before asking the next. A
          draft whose `urgency` is `flexible` is complete without the date.
          Also ask what a professional would need to quote this specific
          job, skip anything the person already said, and write those
          details into `description`; no list of job questions is
          prescribed. Send the answers in `slots` with `location`. Do not
          ask the optional slots as a list; when what the person has told
          you, at any point in the conversation, gives an optional slot's
          value, send it in `slots` as well as writing it in `description`.
          A body that carries `slots`, `location`, `title`, `budget_amount`
          or `time_preferences` is checked against the service's questions:
          when a required answer is missing, or a value is not accepted, the
          answer is `422` and no draft is saved. A body with none of those
          fields is saved as before.

        This endpoint answers `404` while task handovers are switched off.
        Unknown body keys are ignored. The body is capped at 16384 bytes.

        For server-side callers: its CORS headers allow only `GET` and
        `OPTIONS`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [service_slug, description]
              properties:
                service_slug:
                  type: string
                  description: A service slug from `GET /services`.
                  example: "electricidad"
                professional_id:
                  type: [string, "null"]
                  description: |
                    Optional. The `id` of a professional from
                    `GET /professionals`. Empty or null makes a general request.
                  example: "8e3c5b41-6f2a-4f7e-8b1d-2c0a9d8f6c11"
                open_to_others:
                  type: boolean
                  description: |
                    Optional, with a professional. `true` when the person also
                    wants offers from other professionals right away; `false`
                    or omitted gives the chosen professional the first 24 hours.
                professional_slug:
                  type: [string, "null"]
                  description: |
                    Optional. A ProSite slug from an older link, in place of
                    `professional_id`. Sending both is refused.
                  example: "juan-p-electricista-caba"
                description:
                  type: string
                  minLength: 20
                  maxLength: 2000
                  description: What the person needs done. Measured after trimming.
                  example: "Se corta la luz cuando prendo el horno y el aire a la vez."
                zone_text:
                  type: string
                  maxLength: 120
                  description: Optional. Where the job is, as free text.
                  example: "Palermo"
                preferred_time:
                  type: string
                  enum: [flexible, this_week, next_week, specific_date]
                  description: Optional. When the person wants the job done.
                preferred_date:
                  type: string
                  format: date
                  description: |
                    Required when `preferred_time` is `specific_date`, rejected
                    otherwise. From today up to 90 days ahead, Argentina time.
                slots:
                  type: object
                  additionalProperties: true
                  description: |
                    Optional. The person's answers, keyed by slot key from
                    `GET /services/{service_slug}/requirements`. At most 40
                    keys; each value in the slot's `value_format`.
                location:
                  type: object
                  additionalProperties: false
                  description: |
                    Optional. Where the job is: `neighborhood_slug` (with an
                    optional `city_slug`) from `GET /cities`, `address`, or
                    both. The address is saved as text, and the `201` carries
                    `address_pending: true`.
                  properties:
                    neighborhood_slug:
                      type: string
                    city_slug:
                      type: string
                    address:
                      type: string
                      minLength: 3
                      maxLength: 200
                title:
                  type: string
                  minLength: 5
                  maxLength: 80
                  description: Optional. A short title. Left out, it is taken from the description.
                budget_amount:
                  type: number
                  description: Optional. The person's budget in ARS, a positive number.
                time_preferences:
                  type: array
                  description: Optional. The parts of the day that suit the person.
                  items:
                    type: string
                    enum: [morning, midday, afternoon, evening]
            examples:
              complete:
                summary: A complete request for a plumber
                value:
                  service_slug: "plomeria"
                  description: "Pierde agua la canilla de la cocina y gotea debajo de la mesada."
                  location: { neighborhood_slug: "palermo" }
                  slots:
                    urgency: "esta_semana"
                    fecha_preferida: "2026-10-09"
                  time_preferences: ["morning"]
              general:
                summary: A general request
                value:
                  service_slug: "electricidad"
                  description: "Se corta la luz cuando prendo el horno y el aire a la vez."
                  zone_text: "Palermo"
                  preferred_time: "this_week"
              directed:
                summary: A draft directed to one professional
                value:
                  service_slug: "electricidad"
                  professional_id: "8e3c5b41-6f2a-4f7e-8b1d-2c0a9d8f6c11"
                  open_to_others: false
                  description: "Necesito cambiar el tablero de un departamento de dos ambientes."
                  preferred_time: "flexible"
      responses:
        '201':
          description: The draft was saved.
          content:
            application/json:
              schema:
                type: object
                required: [url, expires_at]
                properties:
                  url:
                    type: string
                    format: uri
                    description: |
                      The link to give the person. The token after
                      `#handover=` is what redeems the draft, so share the
                      link only with that person.
                    example: "https://www.muovi.com.ar/post-task/v2?vertical=electricidad#handover=QwErTyUiOpAsDfGhJkLzXcVbNmQwErTyUiOpAsDfGhJk"
                  expires_at:
                    type: string
                    format: date-time
                    description: When the link stops working if nobody has opened it.
                    example: "2026-09-15T14:50:09.123456+00:00"
                  address_pending:
                    type: boolean
                    description: |
                      Present when the body carried the complete-intake
                      fields. `true` when the body named the task's place (a
                      neighborhood, an address, or both): it is saved without
                      a map point for the person to confirm on the map on
                      Muovi.
        '400':
          description: |
            A body field is missing or out of bounds, or the body is not JSON.
            `details.parameter` names the field when there is one.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                short_description:
                  summary: description too short
                  value:
                    error:
                      code: "invalid_parameter"
                      message: "description must be between 20 and 2000 characters."
                      details: { parameter: "description" }
        '404':
          description: Task handovers are switched off.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                disabled:
                  summary: Switched off
                  value:
                    error:
                      code: "not_found"
                      message: "Not found"
        '413':
          description: The body is larger than 16384 bytes.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                too_large:
                  summary: Body over the cap
                  value:
                    error:
                      code: "payload_too_large"
                      message: "The body must be at most 16384 bytes."
        '422':
          description: |
            The body carried the complete-intake fields and was not accepted.
            No draft was saved and there is no link. `incomplete_intake`
            lists the required questions still unanswered in
            `details.missing`; `invalid_slots` names each refused slot and
            its rule in `details.rejections`, without repeating the value.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                incomplete:
                  summary: A required question is unanswered
                  value:
                    error:
                      code: "incomplete_intake"
                      message: "Required questions are unanswered. Ask the person one question per message, then call again with every answer."
                      details:
                        missing:
                          - key: "urgency"
                            question_es: "¿Para cuándo lo necesitás: hoy, esta semana, es flexible, o es una emergencia?"
                            type: "enum"
                            options:
                              - { value: "hoy", label_es: "hoy" }
                              - { value: "esta_semana", label_es: "esta semana" }
                              - { value: "flexible", label_es: "flexible" }
                              - { value: "emergencia", label_es: "emergencia" }
                            value_format: "One of the option values, as a string."
                invalid:
                  summary: A value is outside the slot's options
                  value:
                    error:
                      code: "invalid_slots"
                      message: "Some slot values were not accepted."
                      details:
                        rejections:
                          - slot_key: "urgency"
                            reason_code: "ENUM_VALUE_NOT_IN_OPTIONS"
                            message: "\"urgency\" must be one of: hoy, esta_semana, flexible, emergencia."
        '429':
          description: |
            Too many requests. The shared `/v1` limit answers with
            `Retry-After`; the per-caller handover limit carries
            `details.retry_after_seconds`; the limit on open drafts per caller
            carries neither.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                per_caller:
                  summary: Per-caller handover limit
                  value:
                    error:
                      code: "rate_limited"
                      message: "Too many handovers. Try again in 42 seconds."
                      details: { retry_after_seconds: 42 }
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          description: Too many drafts are waiting to be opened. Try again later.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                busy:
                  summary: Pending-draft cap reached
                  value:
                    error:
                      code: "temporarily_unavailable"
                      message: "Handovers are temporarily unavailable. Try again later."
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
      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: |
                Catalog code of the professional's earliest-approved typed
                matrícula (for example `electricidad`, `gasista`), or `null`
                when no approved matrícula carries a type.
              example: electricidad
        diploma:
          type: object
          description: |
            Additive (WEB-1255). A diploma (a training certificate, not a
            licence) reviewed by Muovi; `has_matricula` matches a verified
            matrícula only. `type` is set only when the professional has no
            verified matrícula. Absent from responses served while the
            feature is switched off.
          required: [verified, type]
          properties:
            verified:
              type: boolean
              example: true
            type:
              type:
                - string
                - "null"
              description: Catalog code of the diploma (for example `electricidad`), or `null`.
              example: electricidad
        background_check:
          type: boolean
          description: Muovi confirmed a clean criminal-background record for this pro.
          example: true
    Professional:
      type: object
      description: |
        Search-result shape for a professional. Lighter than `ProfessionalDetail`.
        It carries no phone, whatsapp, or email — contact happens only
        on Muovi, from `profile_url`.
      required:
        - id
        - display_name
        - city
        - services
        - rating
        - review_count
        - verifications
        - portfolio
        - profile_url
      properties:
        id:
          type: string
          format: uuid
          description: |
            Identifies the professional. Pass it to
            `GET /professionals/{id}`, `GET /professionals/{id}/reviews` and
            as `professional_id` to `POST /task-handovers`.
          example: "8e3c5b41-6f2a-4f7e-8b1d-2c0a9d8f6c11"
        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:
          description: |
            In a search result, the city of the searched barrio. In the
            detail payload, the professional's own city.
          allOf:
            - $ref: '#/components/schemas/City'
        services:
          type: array
          items:
            $ref: '#/components/schemas/Service'
        rating:
          type: number
          minimum: 0
          maximum: 5
          description: One blended average rating over all of the professional's reviews.
          example: 4.8
        review_count:
          type: integer
          minimum: 0
          description: One blended count of all of the professional's reviews.
          example: 142
        verifications:
          $ref: '#/components/schemas/PublicVerifications'
        portfolio:
          type: array
          items:
            type: string
            format: uri
          description: |
            Portfolio image URLs, the ones the public profile page shows. Up
            to 3 in a search result.
          example:
            - "https://hyciaokddgtufwtptpcz.supabase.co/storage/v1/object/public/task-images/8e3c5b41-6f2a-4f7e-8b1d-2c0a9d8f6c11/portfolio/1771234567890-k3x9q7m2abc.jpg"
        profile_url:
          type: string
          format: uri
          description: |
            The professional's public profile page on Muovi,
            `https://www.muovi.com.ar/profile/{id}`. Present for every
            professional. Public clients must drive end-users here rather
            than scraping or synthesising any off-platform contact handle.
          example: "https://www.muovi.com.ar/profile/8e3c5b41-6f2a-4f7e-8b1d-2c0a9d8f6c11"
    SearchMatch:
      type: object
      description: |
        What a search matched on. Every professional in `data` has a
        coverage area that includes `neighborhood`.
      required:
        - basis
        - service
        - neighborhood
        - city
      properties:
        basis:
          type: string
          enum: [coverage_area_includes_neighborhood]
          description: The rule every returned professional met.
        service:
          type: object
          required: [slug, name]
          properties:
            slug: { type: string, example: "electricidad" }
            name: { type: string, example: "Electricidad" }
        neighborhood:
          $ref: '#/components/schemas/Neighborhood'
        city:
          type: object
          required: [slug, name]
          description: The city of the searched barrio.
          properties:
            slug: { type: string, example: "ar-bue-caba" }
            name: { type: string, example: "CABA" }
    ProfessionalDetail:
      allOf:
        - $ref: '#/components/schemas/Professional'
        - type: object
          description: |
            Detail view — adds `neighborhoods`, `bio`, `specialties` and
            `member_since` on top of the search-result shape, and carries
            up to 6 `portfolio` images.
          required:
            - neighborhoods
            - bio
            - portfolio
            - specialties
            - member_since
          properties:
            neighborhoods:
              type: array
              items:
                $ref: '#/components/schemas/Neighborhood'
              description: |
                The professional's own base label. It is not the list of
                barrios they cover, and a search result does not carry it.
            bio:
              type:
                - string
                - "null"
              description: Free-form pro bio, with phone numbers, emails, WhatsApp links, URLs and `@handles` removed server-side.
              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. On `GET /professionals` it also
        carries `page`; `offset` is `(page - 1) * limit`, `total` is the
        number of professionals in this response, not the number that
        matched, and `has_more` is true only when a later page up to the
        third has at least one professional.
      required: [limit, offset, total, has_more]
      properties:
        limit:
          type: integer
          minimum: 1
          maximum: 50
          example: 20
        offset:
          type: integer
          minimum: 0
          example: 0
        page:
          type: integer
          minimum: 1
          maximum: 3
          description: >-
            `GET /professionals` only: the page answered. Absent on the
            other endpoints.
          example: 1
        total:
          type: integer
          minimum: 0
          description: >-
            Total matching records across all pages. On `GET /professionals`,
            the number of professionals in this response.
          example: 142
        has_more:
          type: boolean
          description: >-
            True when another page exists past `offset + limit`. On
            `GET /professionals`, pages past the third do not exist.
          example: true
    ServiceRequirements:
      type: object
      required: [service_slug, vertical, scope, spec_version, slots, photo_policy]
      properties:
        service_slug:
          type: string
        vertical:
          type: [string, "null"]
          description: The trade whose questions these are, or null when the service asks the general questions.
        scope:
          type: string
          enum: [vertical, universal]
        spec_version:
          type: string
          description: The version of the question set. Changes when the questions change.
        slots:
          type: array
          items:
            $ref: '#/components/schemas/RequirementSlot'
        photo_policy:
          type: object
          required: [mode, min, target_es, why_es]
          description: Whether photos help. Photos are added by the person on Muovi.
          properties:
            mode:
              type: string
              enum: [optional, recommended, required_for_estimate, required_for_publish]
            min:
              type: [integer, "null"]
            target_es:
              type: [string, "null"]
            why_es:
              type: [string, "null"]
    RequirementSlot:
      type: object
      required: [key, question_es, type, options, value_format, required, ask_if]
      properties:
        key:
          type: string
          description: The key to send the answer under in `slots`.
        question_es:
          type: string
          description: The question, in Spanish.
        type:
          type: string
          enum: [enum, boolean, number, text, location_resolved, date, budget]
        options:
          type: [array, "null"]
          description: The values a choice accepts, with the words Muovi shows for each.
          items:
            type: object
            required: [value, label_es]
            properties:
              value:
                type: string
              label_es:
                type: string
        value_format:
          type: string
          description: What to send as the value.
        required:
          type: boolean
        ask_if:
          type: [object, "null"]
          description: |
            When present, ask this only if the named slot holds the stated
            value: `{slot, eq}`, `{slot, in}`, `{slot, gte}` or `{slot, lte}`.
    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."
