openapi: 3.1.0
info:
  title: Price Monitor API
  version: 2026-08-01
  description: |
    Async-first REST API for normalized public-page monitoring. The API is
    served below /v1. Use the relative server for a deployed application or
    the localhost server for local development. Additive response fields may
    be introduced without a version bump. Non-empty write bodies must use
    application/json and are limited to 1 MiB; larger bodies return 413.
    Connected workspaces share 120 reads and 30 writes per minute per route
    group across all API keys. Rate-limit, monthly API-request quota, and timed
    manual-check cooldown responses include Retry-After. Other plan, monthly,
    and capacity quota responses may not include a recovery time; follow the
    error message to change the request, free capacity, change plan, or wait
    for the relevant monthly allowance to reset.
    Gateway proof headers are internal platform plumbing and are never sent by
    public API clients.
servers:
  - url: /v1
    description: Current deployment (relative URL)
  - url: http://localhost:3000/v1
    description: Local development
security:
  - bearerAuth: []
x-common-response-headers: &commonResponseHeaders
  X-Request-Id:
    $ref: "#/components/headers/RequestId"
  RateLimit-Remaining:
    $ref: "#/components/headers/RateLimitRemaining"
  RateLimit-Reset:
    $ref: "#/components/headers/RateLimitReset"
  Retry-After:
    $ref: "#/components/headers/RetryAfter"
  Idempotency-Replayed:
    $ref: "#/components/headers/IdempotencyReplayed"
  Location:
    $ref: "#/components/headers/Location"
tags:
  - name: Products
    description: Resolve and read normalized products.
  - name: Watches
    description: Schedule recurring checks and read their runs.
  - name: Changes
    description: Read field-level changes.
  - name: Webhooks
    description: Manage webhook endpoints and deliveries.
  - name: Operations
    description: Poll asynchronous work returned by write operations.
  - name: Account
    description: Usage and service health.
paths:
  /products/resolve:
    post:
      tags: [Products]
      operationId: resolveProduct
      summary: Resolve a public URL
      description: |
        Starts an asynchronous resolution. The same Idempotency-Key may be
        retried for 24 hours with the exact same serialized JSON body, including
        whitespace and property order. Poll data.statusUrl until
        status is succeeded or failed.
      x-required-scopes: [products:resolve]
      x-idempotency-required: true
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ResolveRequest"
      responses:
        "202":
          description: Resolution accepted.
          headers: *commonResponseHeaders
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ResolveAcceptedResponse"
        default:
          $ref: "#/components/responses/Error"
  /operations/{id}:
    get:
      tags: [Operations]
      operationId: getOperation
      summary: Poll an asynchronous operation
      description: |
        Any one of products:read, products:resolve, watches:check, or watches:write
        permits polling an operation in the same workspace. HTTP 200 reports
        the operation state, including failed; inspect data.status. This response
        does not include a detailed failure reason.
      x-required-scopes:
        [products:read, products:resolve, watches:check, watches:write]
      parameters:
        - $ref: "#/components/parameters/Id"
      responses:
        "200":
          description: Current operation state.
          headers: *commonResponseHeaders
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OperationResponse"
        default:
          $ref: "#/components/responses/Error"
  /products/{id}:
    get:
      tags: [Products]
      operationId: getProduct
      summary: Get a normalized product
      x-required-scopes: [products:read]
      parameters:
        - $ref: "#/components/parameters/Id"
      responses:
        "200":
          description: Current normalized product projection.
          headers: *commonResponseHeaders
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ProductResponse"
        default:
          $ref: "#/components/responses/Error"
  /products/{id}/history:
    get:
      tags: [Products]
      operationId: getProductHistory
      summary: List product observations and changes
      description: |
        By default, returns change-linked observations only. Set include to
        no_change_runs to include every observation. from and to are inclusive
        Unix timestamps in milliseconds. fields is a comma-separated list of
        exact changed paths (for example price.amountMinor,stockStatus) used to
        filter fields inside each returned change. Parent prefixes are not
        expanded; the observation object is not filtered by fields.
      x-required-scopes: [products:read]
      parameters:
        - $ref: "#/components/parameters/Id"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/From"
        - $ref: "#/components/parameters/To"
        - $ref: "#/components/parameters/Fields"
        - $ref: "#/components/parameters/Include"
      responses:
        "200":
          description: Paginated history.
          headers: *commonResponseHeaders
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ProductHistoryResponse"
        default:
          $ref: "#/components/responses/Error"
  /watches:
    get:
      tags: [Watches]
      operationId: listWatches
      summary: List watches
      x-required-scopes: [watches:read]
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/WatchStatus"
      responses:
        "200":
          description: Paginated watches.
          headers: *commonResponseHeaders
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WatchPageResponse"
        default:
          $ref: "#/components/responses/Error"
    post:
      tags: [Watches]
      operationId: createWatch
      summary: Create a watch
      description: |
        Provide exactly one of productId or url. A productId can return 201
        immediately; a URL that needs resolution returns 202 and includes an
        operationId/statusUrl together with the full watch projection. The
        Idempotency-Key is required for safe retries. Minimum intervals are 60
        minutes for trial, 15 for starter, 5 for pro, and 1 for enterprise; a
        shorter interval returns 429 QUOTA_EXCEEDED.
      x-required-scopes: [watches:write]
      x-idempotency-required: true
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateWatchRequest"
      responses:
        "201":
          description: Watch created and ready.
          headers: *commonResponseHeaders
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WatchResponse"
        "202":
          description: Watch created while its product is resolving.
          headers: *commonResponseHeaders
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WatchResolvingResponse"
        default:
          $ref: "#/components/responses/Error"
  /watches/{id}:
    parameters:
      - $ref: "#/components/parameters/Id"
    get:
      tags: [Watches]
      operationId: getWatch
      summary: Get a watch
      x-required-scopes: [watches:read]
      responses:
        "200":
          description: Watch projection.
          headers: *commonResponseHeaders
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WatchResponse"
        default:
          $ref: "#/components/responses/Error"
    patch:
      tags: [Watches]
      operationId: updateWatch
      summary: Update a watch
      description: Partial update. Only name, status, tags, timezone, schedule.intervalMinutes, monitoredFields, and rules are accepted.
      x-required-scopes: [watches:write]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdateWatchRequest"
      responses:
        "200":
          description: Updated watch.
          headers: *commonResponseHeaders
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WatchResponse"
        default:
          $ref: "#/components/responses/Error"
    delete:
      tags: [Watches]
      operationId: archiveWatch
      summary: Archive a watch
      x-required-scopes: [watches:write]
      responses:
        "204":
          description: Watch archived. The response has no body.
          headers: *commonResponseHeaders
        default:
          $ref: "#/components/responses/Error"
  /watches/{id}/checks:
    post:
      tags: [Watches]
      operationId: checkWatch
      summary: Queue an immediate check
      description: Manual checks have a 60-second per-watch cooldown. Poll the returned operation URL.
      x-required-scopes: [watches:check]
      x-idempotency-required: true
      parameters:
        - $ref: "#/components/parameters/Id"
        - $ref: "#/components/parameters/IdempotencyKey"
      responses:
        "202":
          description: Check accepted.
          headers: *commonResponseHeaders
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CheckAcceptedResponse"
        default:
          $ref: "#/components/responses/Error"
  /watches/{id}/runs:
    get:
      tags: [Watches]
      operationId: listWatchRuns
      summary: List check runs for a watch
      x-required-scopes: [watches:read]
      parameters:
        - $ref: "#/components/parameters/Id"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: Paginated check runs.
          headers: *commonResponseHeaders
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RunPageResponse"
        default:
          $ref: "#/components/responses/Error"
  /changes:
    get:
      tags: [Changes]
      operationId: listChanges
      summary: List field-level changes
      description: from and to filter inclusive detectedAt Unix milliseconds. clientReference resolves a watch reference only when watchId is absent. When both are supplied, watchId takes precedence. An ambiguous clientReference returns 409 VALIDATION_ERROR.
      x-required-scopes: [changes:read]
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/WatchId"
        - $ref: "#/components/parameters/ProductId"
        - $ref: "#/components/parameters/ClientReference"
        - $ref: "#/components/parameters/ChangeType"
        - $ref: "#/components/parameters/Field"
        - $ref: "#/components/parameters/From"
        - $ref: "#/components/parameters/To"
      responses:
        "200":
          description: Paginated changes.
          headers: *commonResponseHeaders
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ChangePageResponse"
        default:
          $ref: "#/components/responses/Error"
  /changes/{id}:
    get:
      tags: [Changes]
      operationId: getChange
      summary: Get one change
      x-required-scopes: [changes:read]
      parameters:
        - $ref: "#/components/parameters/Id"
      responses:
        "200":
          description: Field-level change.
          headers: *commonResponseHeaders
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ChangeResponse"
        default:
          $ref: "#/components/responses/Error"
  /webhook-endpoints:
    get:
      tags: [Webhooks]
      operationId: listWebhookEndpoints
      summary: List webhook endpoints
      x-required-scopes: [webhooks:read]
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: Paginated webhook endpoints. URLs may be redacted in a read projection.
          headers: *commonResponseHeaders
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WebhookEndpointPageResponse"
        default:
          $ref: "#/components/responses/Error"
    post:
      tags: [Webhooks]
      operationId: createWebhookEndpoint
      summary: Create a webhook endpoint
      description: The generated secret is returned on creation and exact idempotency replays for 24 hours. Store it securely; endpoint read responses do not include it.
      x-required-scopes: [webhooks:write]
      x-idempotency-required: true
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateWebhookEndpointRequest"
      responses:
        "201":
          description: Endpoint created; save the returned signing secret.
          headers: *commonResponseHeaders
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WebhookEndpointCreatedResponse"
        default:
          $ref: "#/components/responses/Error"
  /webhook-endpoints/{id}:
    parameters:
      - $ref: "#/components/parameters/Id"
    patch:
      tags: [Webhooks]
      operationId: updateWebhookEndpoint
      summary: Update a webhook endpoint
      x-required-scopes: [webhooks:write]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdateWebhookEndpointRequest"
      responses:
        "200":
          description: Updated endpoint projection.
          headers: *commonResponseHeaders
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WebhookEndpointResponse"
        default:
          $ref: "#/components/responses/Error"
    delete:
      tags: [Webhooks]
      operationId: disableWebhookEndpoint
      summary: Disable a webhook endpoint
      x-required-scopes: [webhooks:write]
      responses:
        "204":
          description: Endpoint disabled. The response has no body.
          headers: *commonResponseHeaders
        default:
          $ref: "#/components/responses/Error"
  /webhook-endpoints/{id}/test:
    post:
      tags: [Webhooks]
      operationId: testWebhookEndpoint
      summary: Queue a test delivery
      description: The endpoint must be active; testing a disabled endpoint returns 409 VALIDATION_ERROR without creating an event or delivery.
      x-required-scopes: [webhooks:write]
      x-idempotency-required: true
      parameters:
        - $ref: "#/components/parameters/Id"
        - $ref: "#/components/parameters/IdempotencyKey"
      responses:
        "202":
          description: Test delivery queued.
          headers: *commonResponseHeaders
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WebhookDeliveryAcceptedResponse"
        default:
          $ref: "#/components/responses/Error"
  /webhook-endpoints/{id}/deliveries:
    get:
      tags: [Webhooks]
      operationId: listWebhookDeliveries
      summary: List deliveries for an endpoint
      x-required-scopes: [webhooks:read]
      parameters:
        - $ref: "#/components/parameters/Id"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: Paginated delivery log.
          headers: *commonResponseHeaders
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WebhookDeliveryPageResponse"
        default:
          $ref: "#/components/responses/Error"
  /webhook-deliveries/{id}/replay:
    post:
      tags: [Webhooks]
      operationId: replayWebhookDelivery
      summary: Replay a retained delivery
      description: Replay is available for 30 days and keeps the original business event ID. The new delivery has its own delivery ID.
      x-required-scopes: [webhooks:replay]
      x-idempotency-required: true
      parameters:
        - $ref: "#/components/parameters/Id"
        - $ref: "#/components/parameters/IdempotencyKey"
      responses:
        "202":
          description: Replay queued.
          headers: *commonResponseHeaders
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WebhookDeliveryAcceptedResponse"
        default:
          $ref: "#/components/responses/Error"
  /usage:
    get:
      tags: [Account]
      operationId: getUsage
      summary: Get current-month usage
      description: The period is the current UTC calendar month in YYYY-MM format.
      x-required-scopes: [usage:read]
      responses:
        "200":
          description: Usage counters and plan limits.
          headers: *commonResponseHeaders
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/UsageResponse"
        default:
          $ref: "#/components/responses/Error"
  /health:
    get:
      tags: [Account]
      operationId: getHealth
      summary: Check service health
      description: |
        Public liveness endpoint; no bearer token is required. Deployment
        readiness is exposed separately by the application health route.
      security: []
      x-required-scopes: []
      responses:
        "200":
          description: Service is responding.
          headers:
            X-Request-Id:
              $ref: "#/components/headers/RequestId"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HealthResponse"
        default:
          $ref: "#/components/responses/Error"
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: API key (pm_...)
      description: "Send `Authorization: Bearer <API_KEY>`. Keys are workspace-bound and scoped."
  parameters:
    Id:
      name: id
      in: path
      required: true
      schema:
        type: string
        minLength: 1
    Limit:
      name: limit
      in: query
      description: Page size. Defaults to 50; maximum 100.
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 50
    Cursor:
      name: cursor
      in: query
      description: Opaque cursor returned as page.nextCursor. Do not decode or construct it.
      schema:
        type: string
        minLength: 1
        maxLength: 2000
    From:
      name: from
      in: query
      description: Inclusive Unix timestamp in milliseconds.
      schema:
        type: integer
        format: int64
    To:
      name: to
      in: query
      description: Inclusive Unix timestamp in milliseconds.
      schema:
        type: integer
        format: int64
    Fields:
      name: fields
      in: query
      description: Comma-separated normalized field paths. Used to filter history change fields.
      schema:
        type: string
        maxLength: 1000
    Include:
      name: include
      in: query
      description: Include only changed observations by default, or every observation.
      schema:
        type: string
        enum: [changes_only, no_change_runs]
        default: changes_only
    WatchStatus:
      name: status
      in: query
      schema:
        type: string
        enum: [draft, resolving, active, paused, degraded, archived]
    WatchId:
      name: watchId
      in: query
      schema:
        type: string
    ProductId:
      name: productId
      in: query
      schema:
        type: string
    ClientReference:
      name: clientReference
      in: query
      schema:
        type: string
        maxLength: 200
    ChangeType:
      name: type
      in: query
      description: Change kind, such as price or field.
      schema:
        type: string
    Field:
      name: field
      in: query
      description: Exact normalized field path to match.
      schema:
        type: string
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      description: 8–255 characters. A key is scoped to the API key and endpoint and can be replayed for 24 hours with identical request body bytes. Whitespace or property-order changes can cause 409 IDEMPOTENCY_CONFLICT. Use a new key for each new action.
      schema:
        type: string
        minLength: 8
        maxLength: 255
  headers:
    RequestId:
      description: Correlation ID. Include it when contacting support.
      schema:
        type: string
    RateLimitRemaining:
      description: Requests remaining in the current workspace route-group minute window; zero when the monthly API-request quota is exhausted. Present after rate-limit evaluation, not on every response.
      schema:
        type: integer
        minimum: 0
    RateLimitReset:
      description: Unix timestamp in seconds for the route-group minute reset, or the next UTC month when the monthly API-request quota is exhausted.
      schema:
        type: integer
        format: int64
    RetryAfter:
      description: |
        Seconds to wait before retrying a route-group rate limit, monthly
        API-request quota, or timed manual-check cooldown. Other plan, monthly,
        or capacity quota responses can omit this header when there is no
        supplied recovery time.
      schema:
        type: integer
        minimum: 1
    IdempotencyReplayed:
      description: "`true` when the response was served from an idempotency record."
      schema:
        type: string
        enum: ["true"]
    Location:
      description: Relative URL of the created resource or operation when supplied.
      schema:
        type: string
  responses:
    Error:
      description: |
        Error envelope. Common statuses are 400 malformed request or missing
        idempotency key, 401 authentication, 403 scope, 404 missing resource,
        409 conflict, 413 body too large, 415 unsupported media type,
        422 validation, 429 rate/quota, and 500/503 transient
        service errors. Rate-limit and manual-check cooldown 429 responses
        include Retry-After, as does monthly API-request exhaustion. Other plan,
        monthly, and capacity quota responses may
        omit it. The error.docsUrl points to the matching anchor in /docs/api.
      headers: *commonResponseHeaders
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
  schemas:
    ResourceType:
      type: string
      enum:
        [
          retail_product,
          property_listing,
          travel_fare,
          ferry_fare,
          public_tender,
          saas_plan,
          industrial_part,
          generic_price_page,
        ]
    ManualExtraction:
      type: object
      required: [version, fields]
      additionalProperties: false
      properties:
        version:
          type: integer
          const: 1
        fields:
          type: array
          minItems: 1
          maxItems: 32
          uniqueItems: true
          items:
            $ref: "#/components/schemas/ManualExtractionField"
        currency:
          type: string
          pattern: "^[A-Z]{3}$"
      description: Manual selectors are additionally checked against the resource type's supported monitorable paths and parser kind.
    ManualExtractionField:
      type: object
      required: [path, selector, valueFrom, parser, required]
      additionalProperties: false
      properties:
        path:
          type: string
          minLength: 1
          maxLength: 120
          pattern: '^[a-zA-Z][a-zA-Z0-9]*(\.[a-zA-Z][a-zA-Z0-9]*)*$'
        selector:
          type: string
          minLength: 1
          maxLength: 256
        valueFrom:
          type: string
          enum: [text, content, value, datetime, data-price]
        parser:
          type: string
          enum:
            [money, minor_money, availability, text, number, boolean, datetime]
        required:
          type: boolean
    ResolveRequest:
      type: object
      required: [url]
      additionalProperties: false
      properties:
        url:
          type: string
          format: uri
        resourceType:
          $ref: "#/components/schemas/ResourceType"
        mode:
          type: string
          const: async
          default: async
        localeHint:
          type: string
          minLength: 1
          maxLength: 35
          description: Valid BCP 47 locale.
        clientReference:
          type: string
          maxLength: 200
        metadata:
          type: object
          maxProperties: 50
          additionalProperties:
            type: string
        manualExtraction:
          $ref: "#/components/schemas/ManualExtraction"
    CreateWatchRequest:
      type: object
      required: [name, schedule, monitor, notifications]
      additionalProperties: false
      properties:
        productId:
          type: [string, "null"]
        url:
          type: [string, "null"]
          format: uri
        resourceType:
          $ref: "#/components/schemas/ResourceType"
        manualExtraction:
          $ref: "#/components/schemas/ManualExtraction"
        name:
          type: string
          minLength: 1
          maxLength: 200
        schedule:
          type: object
          required: [intervalMinutes, timezone]
          additionalProperties: false
          properties:
            intervalMinutes:
              type: integer
              minimum: 1
              description: Plan minimums are trial 60, starter 15, pro 5, and enterprise 1 minute.
            timezone:
              type: string
              minLength: 1
              maxLength: 100
              description: Valid IANA timezone name.
        monitor:
          $ref: "#/components/schemas/WatchMonitor"
        notifications:
          type: object
          required: [email, webhookEndpointIds]
          additionalProperties: false
          properties:
            email:
              type: boolean
            webhookEndpointIds:
              type: array
              maxItems: 20
              items:
                type: string
        clientReference:
          type: string
          maxLength: 200
      oneOf:
        - required: [productId]
          properties:
            productId: { type: string, minLength: 1 }
            url: { type: "null" }
        - required: [url]
          properties:
            productId: { type: "null" }
            url: { type: string, format: uri }
    WatchMonitor:
      type: object
      required: [fields]
      additionalProperties: false
      properties:
        fields:
          type: array
          minItems: 1
          items: { type: string }
        minimumConfidence:
          type: number
          minimum: 0
          maximum: 1
        price:
          type: object
          additionalProperties: false
          properties:
            direction: { type: string, enum: [any, increase, decrease] }
            minimumPercent: { type: number, minimum: 0 }
            minimumAmountMinor: { type: integer, minimum: 0 }
        stockTransitions:
          type: array
          maxItems: 20
          items: { type: string, maxLength: 100 }
    UpdateWatchRequest:
      type: object
      additionalProperties: false
      properties:
        name: { type: string, minLength: 1, maxLength: 200 }
        status: { type: string, enum: [active, paused] }
        tags:
          type: array
          maxItems: 20
          items: { type: string, maxLength: 50 }
        timezone: { type: string, minLength: 1, maxLength: 100 }
        schedule:
          type: object
          required: [intervalMinutes]
          additionalProperties: false
          properties:
            intervalMinutes: { type: integer, minimum: 1 }
        monitoredFields:
          type: array
          minItems: 1
          items: { type: string }
        rules:
          $ref: "#/components/schemas/WatchRules"
    WatchRules:
      type: object
      additionalProperties: false
      properties:
        minimumConfidence: { type: number, minimum: 0, maximum: 1 }
        price:
          type: object
          additionalProperties: false
          properties:
            direction: { type: string, enum: [any, increase, decrease] }
            minimumPercent: { type: number, minimum: 0 }
            minimumAmountMinor: { type: integer, minimum: 0 }
        stockTransitions:
          type: array
          maxItems: 20
          items: { type: string, maxLength: 100 }
    CreateWebhookEndpointRequest:
      type: object
      required: [url]
      additionalProperties: false
      properties:
        url:
          type: string
          format: uri
          maxLength: 2048
          pattern: "^https://"
        description: { type: string, maxLength: 500, default: "" }
        subscribedEvents:
          type: array
          minItems: 1
          maxItems: 20
          default: [change.detected]
          items:
            $ref: "#/components/schemas/WebhookEventType"
    UpdateWebhookEndpointRequest:
      type: object
      additionalProperties: false
      properties:
        url:
          type: string
          format: uri
          maxLength: 2048
          pattern: "^https://"
        description: { type: string, maxLength: 500 }
        subscribedEvents:
          type: array
          minItems: 1
          maxItems: 20
          items:
            $ref: "#/components/schemas/WebhookEventType"
        status: { type: string, enum: [active, disabled] }
    Money:
      type: object
      required: [amountMinor, currency]
      additionalProperties: false
      properties:
        amountMinor: { type: integer, format: int64 }
        currency: { type: string, pattern: "^[A-Z]{3}$" }
        formatted: { type: string }
    Product:
      type: object
      required: [id, type, status, confidence, links]
      additionalProperties: false
      properties:
        id: { type: string }
        type: { $ref: "#/components/schemas/ResourceType" }
        canonicalUrl:
          type: string
          description: Canonical URL projection. Sensitive parts may be redacted; invalid legacy URLs appear as '[REDACTED_UNSAFE_URL]'. Do not assume this is a reusable fetch URL.
        sourceDomain: { type: string }
        externalId: { type: string }
        clientReference: { type: string }
        metadata: { type: object, additionalProperties: { type: string } }
        title: { type: string }
        description: { type: string }
        imageUrl: { type: string, format: uri }
        price: { $ref: "#/components/schemas/Money" }
        previousPrice: { $ref: "#/components/schemas/Money" }
        listPrice: { $ref: "#/components/schemas/Money" }
        shippingCost: { $ref: "#/components/schemas/Money" }
        totalPrice: { $ref: "#/components/schemas/Money" }
        unitPrice: { $ref: "#/components/schemas/Money" }
        stockStatus: { type: string }
        stockQuantity: { type: integer, format: int64 }
        stock:
          type: object
          additionalProperties: false
          properties:
            status: { type: string }
            quantity: { type: [integer, "null"], format: int64 }
        shipping:
          type: object
          additionalProperties: false
          properties:
            cost: { $ref: "#/components/schemas/Money" }
            text: { type: string }
        seller:
          type: object
          additionalProperties: true
          properties:
            name: { type: string }
            id: { type: string }
            url: { type: string, format: uri }
        brand: { type: string }
        sku: { type: string }
        gtin: { type: string }
        availabilityText: { type: string }
        attributes: { type: object, additionalProperties: true }
        vertical: { type: object, additionalProperties: true }
        observedAt: { type: string, format: date-time }
        sourceUpdatedAt: { type: string, format: date-time }
        extractor: { type: object, additionalProperties: true }
        status: { type: string, enum: [ready, pending] }
        confidence: { type: number, minimum: 0, maximum: 1 }
        links:
          type: object
          required: [history]
          properties:
            history: { type: string }
    Watch:
      type: object
      required:
        [
          id,
          targetId,
          productId,
          name,
          status,
          tags,
          intervalMinutes,
          timezone,
          nextRunAt,
          monitoredFields,
          rules,
          consecutiveFailures,
          createdAt,
          updatedAt,
        ]
      additionalProperties: true
      properties:
        id: { type: string }
        targetId: { type: string }
        productId: { type: string }
        name: { type: string }
        status:
          {
            type: string,
            enum: [draft, resolving, active, paused, degraded, archived],
          }
        tags: { type: array, items: { type: string } }
        intervalMinutes: { type: integer, minimum: 1 }
        timezone: { type: string }
        nextRunAt: { type: string, format: date-time }
        lastScheduledAt: { type: integer, format: int64 }
        lastSuccessfulAt: { type: integer, format: int64 }
        consecutiveFailures: { type: integer, minimum: 0 }
        monitoredFields: { type: array, items: { type: string } }
        rules: { $ref: "#/components/schemas/WatchRules" }
        clientReference: { type: string }
        createdBy: { type: string }
        createdAt: { type: integer, format: int64 }
        updatedAt: { type: integer, format: int64 }
        archivedAt: { type: integer, format: int64 }
        lastManualRunAt: { type: integer, format: int64 }
    ChangeField:
      type: object
      required: [path, before, after]
      additionalProperties: true
      properties:
        path: { type: string }
        before: {}
        after: {}
        currency: { type: string, pattern: "^[A-Z]{3}$" }
        beforeCurrency: { type: string, pattern: "^[A-Z]{3}$" }
        afterCurrency: { type: string, pattern: "^[A-Z]{3}$" }
        baselineObservationId: { type: string }
        deltaMinor: { type: integer, format: int64 }
        deltaPercent: { type: number }
        severity: { type: string, enum: [info, positive, warning, critical] }
        provenance:
          { type: array, items: { type: object, additionalProperties: true } }
    Change:
      type: object
      required: [id, eventId, productId, watchId, detectedAt, summary, fields]
      additionalProperties: false
      properties:
        id: { type: string }
        eventId: { type: string }
        productId: { type: string }
        watchId: { type: string }
        detectedAt: { type: string, format: date-time }
        summary: { type: string }
        fields:
          { type: array, items: { $ref: "#/components/schemas/ChangeField" } }
    Operation:
      type: object
      required: [operationId, status, statusUrl]
      additionalProperties: false
      properties:
        operationId: { type: string }
        status: { type: string, enum: [pending, succeeded, failed] }
        productId: { type: string }
        statusUrl: { type: string }
    WatchCreated:
      description: Full resolving watch projection plus the operation to poll.
      allOf:
        - $ref: "#/components/schemas/Watch"
        - type: object
          required: [operationId, statusUrl]
          properties:
            operationId: { type: string }
            statusUrl: { type: string }
    CheckRun:
      type: object
      required:
        [
          id,
          watchId,
          jobId,
          attempt,
          status,
          strategy,
          durationMs,
          artifactIds,
          startedAt,
        ]
      additionalProperties: true
      properties:
        id: { type: string }
        watchId: { type: string }
        jobId: { type: string }
        attempt: { type: integer }
        status: { type: string }
        strategy: { type: string }
        httpStatus: { type: integer }
        durationMs: { type: integer, format: int64 }
        bytes: { type: integer, format: int64 }
        confidence: { type: number, minimum: 0, maximum: 1 }
        errorCategory: { type: string }
        safeMessage: { type: string }
        observationId: { type: string }
        artifactIds: { type: array, items: { type: string } }
        startedAt: { type: integer, format: int64 }
        finishedAt: { type: integer, format: int64 }
    Observation:
      type: object
      required:
        [
          id,
          watchId,
          resourceId,
          normalized,
          normalizedHash,
          fieldEvidence,
          extractor,
          observedAt,
          createdAt,
        ]
      additionalProperties: true
      properties:
        id: { type: string }
        watchId: { type: string }
        resourceId: { type: string }
        normalized: { type: object, additionalProperties: true }
        normalizedHash: { type: string }
        fieldEvidence:
          { type: array, items: { type: object, additionalProperties: true } }
        extractor: { type: object, additionalProperties: true }
        observedAt: { type: string, format: date-time }
        sourceUpdatedAt: { type: string, format: date-time }
        previousObservationId: { type: string }
        createdAt: { type: integer, format: int64 }
    WebhookEventType:
      type: string
      enum:
        [
          product.resolved,
          watch.created,
          change.detected,
          check.failed,
          watch.degraded,
          watch.recovered,
        ]
    WebhookEndpointFields:
      type: object
      required:
        [
          id,
          url,
          description,
          subscribedEvents,
          status,
          failureCount,
          createdAt,
          updatedAt,
        ]
      properties:
        id: { type: string }
        url:
          {
            type: string,
            description: "URL may be redacted in read projections; invalid legacy URLs appear as [REDACTED_UNSAFE_URL].",
          }
        description: { type: string }
        subscribedEvents:
          {
            type: array,
            items: { $ref: "#/components/schemas/WebhookEventType" },
          }
        status: { type: string, enum: [active, disabled] }
        failureCount: { type: integer, minimum: 0 }
        createdAt: { type: integer, format: int64 }
        updatedAt: { type: integer, format: int64 }
    WebhookEndpoint:
      allOf:
        - $ref: "#/components/schemas/WebhookEndpointFields"
      unevaluatedProperties: false
    WebhookEndpointCreated:
      allOf:
        - $ref: "#/components/schemas/WebhookEndpointFields"
        - type: object
          required: [secret]
          properties:
            secret:
              {
                type: string,
                minLength: 1,
                description: Signing secret returned on creation and exact idempotency replays for 24 hours; absent from read responses.,
              }
      unevaluatedProperties: false
    WebhookDelivery:
      type: object
      required:
        [id, endpointId, eventId, attempt, status, scheduledAt, createdAt]
      additionalProperties: true
      properties:
        id: { type: string }
        endpointId: { type: string }
        eventId: { type: string }
        attempt: { type: integer, minimum: 0 }
        status: { type: string, enum: [pending, sending, succeeded, failed] }
        scheduledAt: { type: integer, format: int64 }
        startedAt: { type: integer, format: int64 }
        finishedAt: { type: integer, format: int64 }
        responseCode: { type: integer }
        durationMs: { type: integer, format: int64 }
        safeResponseExcerpt: { type: string }
        nextRetryAt: { type: integer, format: int64 }
        createdAt: { type: integer, format: int64 }
    Page:
      type: object
      required: [nextCursor, hasMore]
      additionalProperties: false
      properties:
        nextCursor: { type: [string, "null"] }
        hasMore: { type: boolean }
    ResolveAcceptedResponse:
      type: object
      required: [data]
      properties:
        data:
          allOf:
            - $ref: "#/components/schemas/Operation"
          required: [productId]
    OperationResponse:
      type: object
      required: [data]
      properties:
        data: { $ref: "#/components/schemas/Operation" }
    ProductResponse:
      type: object
      required: [data]
      properties:
        data: { $ref: "#/components/schemas/Product" }
    WatchResponse:
      type: object
      required: [data]
      properties:
        data: { $ref: "#/components/schemas/Watch" }
    WatchResolvingResponse:
      type: object
      required: [data]
      properties:
        data: { $ref: "#/components/schemas/WatchCreated" }
    WatchPageResponse:
      type: object
      required: [data, page]
      properties:
        data: { type: array, items: { $ref: "#/components/schemas/Watch" } }
        page: { $ref: "#/components/schemas/Page" }
    CheckAcceptedResponse:
      $ref: "#/components/schemas/OperationResponse"
    RunPageResponse:
      type: object
      required: [data, page]
      properties:
        data: { type: array, items: { $ref: "#/components/schemas/CheckRun" } }
        page: { $ref: "#/components/schemas/Page" }
    ProductHistoryRow:
      type: object
      required: [observation]
      properties:
        observation: { $ref: "#/components/schemas/Observation" }
        change: { $ref: "#/components/schemas/Change" }
    ProductHistoryResponse:
      type: object
      required: [data, page]
      properties:
        data:
          {
            type: array,
            items: { $ref: "#/components/schemas/ProductHistoryRow" },
          }
        page: { $ref: "#/components/schemas/Page" }
    ChangeResponse:
      type: object
      required: [data]
      properties:
        data: { $ref: "#/components/schemas/Change" }
    ChangePageResponse:
      type: object
      required: [data, page]
      properties:
        data: { type: array, items: { $ref: "#/components/schemas/Change" } }
        page: { $ref: "#/components/schemas/Page" }
    WebhookEndpointResponse:
      type: object
      required: [data]
      properties:
        data: { $ref: "#/components/schemas/WebhookEndpoint" }
    WebhookEndpointCreatedResponse:
      type: object
      required: [data]
      properties:
        data: { $ref: "#/components/schemas/WebhookEndpointCreated" }
    WebhookEndpointPageResponse:
      type: object
      required: [data, page]
      properties:
        data:
          {
            type: array,
            items: { $ref: "#/components/schemas/WebhookEndpoint" },
          }
        page: { $ref: "#/components/schemas/Page" }
    WebhookDeliveryAcceptedResponse:
      type: object
      required: [data]
      properties:
        data:
          type: object
          required: [id, eventId, status]
          properties:
            id: { type: string }
            eventId: { type: string }
            status: { type: string, enum: [pending] }
    WebhookDeliveryPageResponse:
      type: object
      required: [data, page]
      properties:
        data:
          {
            type: array,
            items: { $ref: "#/components/schemas/WebhookDelivery" },
          }
        page: { $ref: "#/components/schemas/Page" }
    UsageMetric:
      type: object
      required: [used, limit]
      properties:
        used: { type: integer, format: int64, minimum: 0 }
        limit: { type: integer, format: int64, minimum: 0 }
    UsageResponse:
      type: object
      required: [data]
      properties:
        data:
          type: object
          required: [period, metrics]
          properties:
            period: { type: string, pattern: '^\d{4}-(0[1-9]|1[0-2])$' }
            plan:
              type: string
              description: Included by the local demo fixture; the connected API currently omits this field. Read metrics for your limits.
            metrics:
              type: object
              required:
                [
                  checks,
                  rendered_checks,
                  api_requests,
                  active_watches,
                  webhook_endpoints,
                ]
              properties:
                checks: { $ref: "#/components/schemas/UsageMetric" }
                rendered_checks: { $ref: "#/components/schemas/UsageMetric" }
                api_requests: { $ref: "#/components/schemas/UsageMetric" }
                active_watches: { $ref: "#/components/schemas/UsageMetric" }
                webhook_endpoints: { $ref: "#/components/schemas/UsageMetric" }
    HealthResponse:
      type: object
      required: [data]
      properties:
        data:
          type: object
          required: [status, service, time]
          properties:
            status: { type: string, const: ok }
            service: { type: string, const: price-monitor-api }
            time: { type: string, format: date-time }
    ErrorResponse:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message, details, requestId, retryable, docsUrl]
          properties:
            code:
              type: string
              description: DOMAIN_BLOCKED and RESOLVE_FAILED are reserved and not emitted by current REST handlers. LOW_CONFIDENCE is used in check-run diagnostics rather than as a top-level REST error. Operation failures are reported by data.status = failed on the polling endpoint.
              enum:
                [
                  AUTHENTICATION_REQUIRED,
                  INSUFFICIENT_SCOPE,
                  NOT_FOUND,
                  VALIDATION_ERROR,
                  URL_UNSAFE,
                  DOMAIN_BLOCKED,
                  UNSUPPORTED_CONTENT,
                  RESOLVE_FAILED,
                  LOW_CONFIDENCE,
                  RUN_IN_PROGRESS,
                  QUOTA_EXCEEDED,
                  RATE_LIMITED,
                  IDEMPOTENCY_CONFLICT,
                  SERVICE_UNAVAILABLE,
                  INTERNAL_ERROR,
                ]
            message: { type: string }
            details: { type: array, items: {} }
            requestId: { type: string }
            retryable: { type: boolean }
            docsUrl:
              {
                type: string,
                description: Relative URL such as /docs/api#validation_error.,
              }
