openapi: 3.1.0
info:
  title: Parceled REST API
  description: |
    Real estate data for AI agents. REST surface over the Parceled
    platform: parcel boundaries (vector tiles), assessor-sourced
    property records (owners, address, land size, sale history), plus
    supplementary roof permits and hail-swath datasets. 135M+ parcels
    across all 50 US states.

    This is a thin wrapper around the same field-level-billed GraphQL /
    MCP endpoints at https://parceled.ai/api and https://parceled.ai/api/mcp
    — it exists so agent frameworks expecting OpenAPI (OpenAI GPT Actions,
    LangChain OpenAPIToolkit, Zapier AI Actions) can consume Parceled
    without speaking GraphQL or MCP JSON-RPC.

    Parcel boundary polygons are served as Mapbox Vector Tiles, not as
    JSON — see /llms-full.txt section 4b for how to embed them in your
    own Mapbox / MapLibre map. Native MCP clients should prefer /api/mcp.
    See:
      - https://parceled.ai/llms.txt
      - https://parceled.ai/.well-known/mcp.json
  version: 1.0.0
  contact:
    name: Parceled Support
    email: hello@parceled.ai
    url: https://parceled.ai
  license:
    name: Commercial
    url: https://parceled.ai/tos

servers:
  - url: https://parceled.ai
    description: Production

security:
  - bearerAuth: []

tags:
  - name: Data
    description: Property data queries (billable, field-level pricing).
  - name: Account
    description: Balance, pricing, and usage info (free).

paths:
  /api/v1/accounts:
    post:
      tags: [Account]
      operationId: createAccount
      summary: Create a Parceled account and receive your first API key.
      description: |
        Unauthenticated bootstrap for REST / script / non-MCP consumers.
        MCP clients should use the OAuth 2.1 flow on /api/mcp instead
        (Dynamic Client Registration + PKCE + magic-link email).

        **Email is optional.** Two modes:

        1. **With email** — normal account. Recoverable: if the apiKey
           is lost, run OAuth with the same email and the server
           rotates a new key onto the same account (credits/tier
           preserved). Recommended for anything you'll pay for.

        2. **Without email** — anonymous account. Free tier only. The
           apiKey is the ONLY credential; lose it and the account is
           stranded. Best for single-use bots and agents without an
           operator-provided email.

        Rate-limited to 10 creations per IP per hour. The returned
        `apiKey` is shown ONCE — store it immediately. There is no
        web dashboard to retrieve it later.

        Free tier: 100 MCP calls/month, all fields, no credit card.
      security: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                email:
                  type: string
                  format: email
                  nullable: true
                  description: Optional. Omit for an anonymous account.
                  example: you@example.com
                organizationName:
                  type: string
                  maxLength: 200
                  nullable: true
                  example: Acme Corp
      responses:
        '201':
          description: Account created. The apiKey is plaintext and returned exactly once.
          content:
            application/json:
              schema:
                type: object
                properties:
                  accountId: { type: string }
                  apiKey:
                    type: string
                    description: Store immediately — not retrievable later.
                    example: prc_live_abc123…
                  apiKeyId: { type: string }
                  keyPrefix: { type: string, example: prc_live_abc }
                  tier: { type: string, enum: [free] }
                  anonymous:
                    type: boolean
                    description: True when no email was provided. Anonymous accounts have no OAuth recovery.
                  message: { type: string }
                  nextSteps:
                    type: object
                    additionalProperties: { type: string }
        '400': { $ref: '#/components/responses/BadRequest' }
        '409':
          description: An account already exists for that email.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string, example: conflict }
                  message: { type: string }
        '429':
          description: Rate limit exceeded. Retry-After header indicates seconds to wait.
          headers:
            Retry-After:
              schema: { type: integer }
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string, example: rate_limited }
                  message: { type: string }

  /api/v1/ping:
    get:
      tags: [Account]
      operationId: ping
      summary: Liveness probe. No auth — confirms the server is reachable.
      description: |
        Returns server info without authentication. Use before triggering
        any OAuth flow to confirm the endpoint is alive and compatible.
      security: []
      responses:
        '200':
          description: Server is up.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, example: true }
                  service: { type: string, example: parceled }
                  version: { type: string, example: '1.1.0' }
                  endpoints:
                    type: object
                    additionalProperties: { type: string }

  /api/v1/pricing:
    get:
      tags: [Account]
      operationId: getPricing
      summary: Get current per-field credit costs and credit pack tiers.
      description: Public. No auth required. Safe to call frequently.
      security: []
      responses:
        '200':
          description: Pricing model.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Pricing' }

  /api/v1/balance:
    get:
      tags: [Account]
      operationId: getBalance
      summary: Get remaining credits and current billing tier for the calling API key.
      responses:
        '200':
          description: Balance snapshot.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      credits: { type: integer, example: 87 }
                      tier: { type: string, enum: [free, credits, payg] }
                      budgetCapUsd: { type: number, nullable: true }
                  billing: { $ref: '#/components/schemas/BillingSummary' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /api/v1/check-availability:
    get:
      tags: [Data]
      operationId: checkAvailability
      summary: Probe which datasets exist for a coordinate. Free, public, no auth required.
      description: |
        Returns which datasets (parcel, roof permits, hail history) exist
        for the given lat/lng. Costs zero credits. Use this before
        calling /api/v1/parcels to avoid paying for data that is not there.

        **No authentication required.** Safe for agents to call before
        any account exists. If a valid Bearer token is sent, the request
        is attributed to that account's usage history and the response
        includes a `billing` block for shape consistency. Anonymous
        calls return just `{ data }` (no billing block).

        Rate limits:
        - Anonymous: 60 requests per minute per IP.
        - Authenticated: the account's normal call quota (free tier is
          100 calls/month; check_availability counts toward it but is
          always 0 credits).
      security: []
      parameters:
        - $ref: '#/components/parameters/Lat'
        - $ref: '#/components/parameters/Lng'
      responses:
        '200':
          description: Availability flags. `billing` is present only when a Bearer token was supplied.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      parcel: { type: boolean, description: True if a parcel exists at this coordinate. }
                      roofPermits: { type: boolean, description: True if roof permits exist for the parcel at this coordinate. }
                      hailAreas: { type: boolean, description: True if any historical hail swath intersects this parcel. }
                      currentHailAreas: { type: boolean, description: True if a hail swath from the last 14 days intersects this parcel. }
                  billing: { $ref: '#/components/schemas/BillingSummary' }
                  anonymous:
                    type: boolean
                    description: Present and true on anonymous calls.
                  hint:
                    type: string
                    description: Present on anonymous calls — points at /api/v1/accounts for full access.
        '400': { $ref: '#/components/responses/BadRequest' }
        '429':
          description: Anonymous rate limit exceeded. Retry-After header included.
          headers:
            Retry-After:
              schema: { type: integer }
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string, example: rate_limited }
                  message: { type: string }

  /api/v1/parcels:
    get:
      tags: [Data]
      operationId: searchParcels
      summary: Fetch the parcel (and optionally permits, hail) at a coordinate.
      description: |
        Billable. Field-level pricing — you only pay for fields in the
        `include` list that actually return data.

        **Default if `include` is omitted:** `parcel` only (1-credit
        ceiling). To fetch permits or hail you must opt in explicitly.

        Recommended flow:
          1. checkAvailability(lat, lng)  — free
          2. Confirm dataset selection with the end user
          3. searchParcels(lat, lng, include=[only the ones they asked for])

        Every response includes a `hints` block listing the other
        available fields and their cost — no need to memorize.
      parameters:
        - $ref: '#/components/parameters/Lat'
        - $ref: '#/components/parameters/Lng'
        - name: include
          in: query
          required: false
          description: |
            Comma-separated list of fields to fetch and bill for.
            Allowed: parcel (1 credit), roofPermits (2), hailAreas (1),
            currentHailAreas (1). Example: `parcel,hailAreas`.

            **Default if omitted:** `parcel` only (1-credit ceiling).
            To fetch permits or hail, you must opt in explicitly. The
            response includes a `hints` block listing the other fields
            you could request on the next call.
          schema:
            type: string
            default: parcel
            example: parcel,hailAreas
      responses:
        '200':
          description: Parcel data plus billing summary.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Parcel' }
                  billing: { $ref: '#/components/schemas/BillingSummary' }
                  hints: { $ref: '#/components/schemas/Hints' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402':
          description: Pre-flight billing check failed — out of credits or over budget cap.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string, example: billing_error }
                  message: { type: string }
                  maxCost: { type: integer }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: Parceled API key (prc_live_*)
      description: |
        There is no web dashboard. Two ways to obtain a key:

        1. OAuth 2.1 flow discoverable at
           https://parceled.ai/.well-known/oauth-authorization-server
           (supports Dynamic Client Registration per RFC 7591). Any
           compliant MCP client runs this automatically on first tool
           call.
        2. Unauthenticated GraphQL mutation (one-shot, script-friendly):
             mutation { createParceledAccount(email: "you@example.com") { apiKey } }
           POST to https://parceled.ai/api. The returned apiKey is the
           Bearer token.

  parameters:
    Lat:
      name: lat
      in: query
      required: true
      description: Latitude in decimal degrees (WGS84).
      schema: { type: number, format: double, example: 39.7392 }
    Lng:
      name: lng
      in: query
      required: true
      description: Longitude in decimal degrees (WGS84).
      schema: { type: number, format: double, example: -104.9903 }

  responses:
    Unauthorized:
      description: Missing or invalid Bearer token.
      headers:
        WWW-Authenticate:
          description: Advertises the OAuth protected-resource metadata URL (RFC 9728).
          schema: { type: string }
      content:
        application/json:
          schema:
            type: object
            properties:
              error: { type: string, example: unauthorized }
              message: { type: string }
    BadRequest:
      description: Missing or malformed query parameter.
      content:
        application/json:
          schema:
            type: object
            properties:
              error: { type: string, example: bad_request }
              message: { type: string }

  schemas:
    Hints:
      type: object
      description: |
        Present on responses to field-billed endpoints. Teaches the caller
        what other fields could be fetched on the same query, at what cost,
        so they learn the surface without a separate discovery round-trip.
      properties:
        requestedFields:
          type: array
          items: { type: string }
          description: Fields that were included in this call.
        otherAvailableFields:
          type: array
          items:
            type: object
            properties:
              field: { type: string }
              cost: { type: integer }
              label: { type: string }
        message: { type: string }
        appliedDefault:
          type: string
          description: |
            Present when the safe default was used because the caller
            omitted `include`. Tells the caller what to pass next time.

    BillingSummary:
      type: object
      properties:
        totalCost:
          type: integer
          description: Credits charged for this call.
          example: 2
        fieldsCharged:
          type: array
          items:
            type: object
            properties:
              field: { type: string, example: parcel }
              label: { type: string, example: Parcel data }
              cost: { type: integer, example: 1 }
        fieldsEmpty:
          type: array
          items:
            type: object
            properties:
              field: { type: string }
              label: { type: string }
        remainingCredits: { type: integer, example: 85 }
        tier: { type: string, enum: [free, credits, payg] }

    Pricing:
      type: object
      properties:
        model: { type: string, example: field-level }
        description: { type: string }
        currency: { type: string, example: USD }
        creditValueUsd: { type: number, example: 0.01 }
        fieldCosts:
          type: object
          additionalProperties:
            type: object
            additionalProperties:
              type: object
              properties:
                cost: { type: integer }
                label: { type: string }
        creditPacks:
          type: array
          items:
            type: object
            properties:
              credits: { type: integer }
              priceUsd: { type: number }
        tiers:
          type: object
          additionalProperties: { type: string }

    Parcel:
      type: object
      description: |
        HailProProperty wrapper. Top-level keys are only present if they
        were in the request `include` AND had data at the coordinate.
        Boundary polygons are NOT in this object — those are served via
        vector tiles (see /api/parceled/tiles/{layer}/{z}/{x}/{y}).
      nullable: true
      properties:
        parcel:
          type: object
          nullable: true
          properties:
            id: { type: string, description: Internal stable id (FIPS_county + APN). }
            parcelId: { type: string, nullable: true, description: County-issued APN when known. }
            address: { type: string, nullable: true }
            city: { type: string, nullable: true }
            county: { type: string, nullable: true, description: Usually a FIPS code; some rows return a county name. }
            state: { type: string }
            zipcode: { type: string, nullable: true }
            latitude: { type: number, nullable: true }
            longitude: { type: number, nullable: true }
            landSizeAcres: { type: number, nullable: true }
            landSizeSqft: { type: number, nullable: true }
            owner: { type: string, nullable: true, description: Primary owner name when available. Not masked. }
            owner2: { type: string, nullable: true }
            ownerAddress: { type: string, nullable: true }
            ownerCity: { type: string, nullable: true }
            ownerState: { type: string, nullable: true }
            ownerZip: { type: string, nullable: true }
            saleDate: { type: string, nullable: true }
            salePrice: { type: string, nullable: true }
            url: { type: string, nullable: true, description: Assessor URL when known. }
        roofPermits:
          type: array
          nullable: true
          items:
            type: object
            properties:
              permitId: { type: string }
              date: { type: string, format: date-time }
              status: { type: string, nullable: true, description: Jurisdiction-specific (issued, finaled, expired, etc). }
              recordType: { type: string, nullable: true }
              parcelNumber: { type: string, nullable: true }
              permitUrl: { type: string, nullable: true }
              address: { type: string, nullable: true }
              county: { type: string, nullable: true }
              state: { type: string, nullable: true }
              metadata:
                type: object
                nullable: true
                description: Raw fields from the source jurisdiction (contractor, valuation, etc. when available — varies).
        hailAreas:
          type: array
          nullable: true
          items:
            type: object
            properties:
              id: { type: integer }
              ogcFid: { type: integer }
              maxMesh: { type: number, description: Maximum Estimated Size of Hail in mm. }
              importDate: { type: integer, format: int64, description: Epoch milliseconds (BigInt as integer). }
              source: { type: string, nullable: true, example: NWS-SPC }
              zIndex: { type: integer, nullable: true }
        currentHailAreas:
          type: array
          nullable: true
          description: Same shape as hailAreas; only swaths from the last 14 days.
          items:
            type: object
            properties:
              id: { type: integer }
              ogcFid: { type: integer }
              maxMesh: { type: number }
              importDate: { type: integer, format: int64 }
              source: { type: string, nullable: true }
              zIndex: { type: integer, nullable: true }
