openapi: 3.0.3
info:
  title: Rivioo API
  version: "1.0"
  description: >
    App Store and Google Play reviews as JSON. Cache-first: recently requested
    apps return instantly; new apps are fetched live and polled as a job.
  contact:
    email: admin@rivioo.app
servers:
  - url: https://api.rivioo.app/v1
  - url: https://rivioo-api.up.railway.app/api/v1
security:
  - bearerAuth: []
paths:
  /apps/search:
    get:
      summary: Search for an app
      description: Find an app's canonical id. Never counts against your monthly quota.
      parameters:
        - name: q
          in: query
          required: true
          schema: { type: string, minLength: 2 }
        - name: store
          in: query
          schema: { type: string, enum: [ios, android] }
        - name: country
          in: query
          description: Two-letter storefront code. A code the chosen store does not support returns 400 instead of results.
          schema: { type: string, default: us }
      responses:
        "200":
          description: Matching apps with canonical ids
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/AppResult" }
                  meta:
                    type: object
                    properties:
                      count: { type: integer }
        "400": { $ref: "#/components/responses/Error" }
        "401": { $ref: "#/components/responses/Error" }
        "429": { $ref: "#/components/responses/Error" }
  /reviews/fetch:
    post:
      summary: Fetch reviews for an app
      description: >
        Cache hit returns HTTP 200 with the first page of reviews. Cache miss
        or stale data starts a live fetch and returns HTTP 202 with a job to poll.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [store, app_id]
              properties:
                store: { type: string, enum: [ios, android] }
                app_id:
                  type: string
                  description: Android package name or iOS numeric track id
                country:
                  type: string
                  default: us
                  description: >
                    Two-letter storefront code. For the App Store it selects that
                    country's reviews. For Google Play it selects the review languages
                    (English plus the country's main language where English is not the
                    main language) and returns those reviews from every country, since
                    Google Play reviews are per language, not per country.
                limit:
                  type: integer
                  description: Total reviews wanted, clamped to the plan's per-request max
                cache_policy:
                  type: string
                  enum: [use_cache, ask, refresh]
                  default: use_cache
                  description: >
                    How to treat reviews already held for this app and country.
                    use_cache (default) serves held reviews when they are fresh
                    enough (see freshness_days) and at least limit deep, otherwise
                    starts a live fetch; allow_stale true serves older held reviews
                    instead of fetching. ask returns HTTP 200 with
                    status cache_choice_required and delivers no reviews when any
                    are held, so you can ask the user; repeat the request with
                    use_cache or refresh. When nothing is held, ask behaves like
                    use_cache. refresh skips the held reviews and starts a live
                    fetch (allow_stale is ignored); if the monthly quota is used up
                    and reviews are held, those free-to-read reviews are returned
                    instead. Any other value returns 400 VALIDATION_ERROR.
                freshness_days: { type: integer, minimum: 1, maximum: 30, default: 7 }
                allow_stale:
                  type: boolean
                  default: false
                  description: Serve older cached data instantly instead of refreshing
                filters:
                  type: object
                  additionalProperties: false
                  description: Narrows the reviews before paging. Unknown fields return 400.
                  properties:
                    posted_from:
                      type: string
                      format: date-time
                      description: Keep reviews posted at or after this ISO 8601 timestamp (offset required)
                    posted_before:
                      type: string
                      format: date-time
                      description: Keep reviews posted before this ISO 8601 timestamp (offset required); must be after posted_from
                    ratings:
                      type: array
                      minItems: 1
                      maxItems: 5
                      items: { type: integer, minimum: 1, maximum: 5 }
                      description: Star ratings to keep
                    app_version:
                      type: string
                      minLength: 1
                      maxLength: 100
                      description: Exact app version
      responses:
        "200":
          description: >
            Cache hit: the first page of reviews. A review uses quota the first time it is
            delivered to your account; delivering it again within 30 days is free. When
            the monthly quota is used up, only those free reviews are returned and
            meta.quota_limited is true; with none, the response is 429 QUOTA_EXCEEDED.
            With cache_policy ask and reviews held, the body is instead a
            cache_choice_required notice and no reviews are delivered or charged.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/ReviewPage"
                  - type: object
                    properties:
                      status: { type: string, enum: [cache_choice_required] }
                      cache:
                        type: object
                        properties:
                          store: { type: string }
                          app_id: { type: string }
                          country: { type: string }
                          available: { type: integer, description: Reviews held, before filters }
                          synced_at: { type: string, format: date-time, nullable: true }
                          fresh: { type: boolean }
                          sufficient_depth: { type: boolean }
                      meta:
                        type: object
                        properties:
                          requested: { type: integer }
                          reviews_charged: { type: integer }
                      next_action: { type: string }
        "202":
          description: Live fetch started — poll the job
          content:
            application/json:
              schema:
                type: object
                properties:
                  job:
                    type: object
                    properties:
                      id: { type: string, format: uuid }
                      status: { type: string, enum: [queued] }
                  meta:
                    type: object
                    properties:
                      source: { type: string }
                      poll: { type: string }
                      estimated_seconds: { type: integer }
        "400": { $ref: "#/components/responses/Error" }
        "401": { $ref: "#/components/responses/Error" }
        "429": { $ref: "#/components/responses/Error" }
  /jobs/{id}:
    get:
      summary: Check a job's status
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: Job status
          content:
            application/json:
              schema:
                type: object
                properties:
                  job: { $ref: "#/components/schemas/Job" }
        "404": { $ref: "#/components/responses/Error" }
  /jobs/{id}/reviews:
    get:
      summary: Get a completed job's reviews
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: page_size
          in: query
          schema: { type: integer, minimum: 1, maximum: 500, default: 100 }
        - name: limit
          in: query
          description: Total reviews wanted across pages, clamped to the job's size and the plan's per-request max
          schema: { type: integer, minimum: 1 }
        - name: ratings
          in: query
          description: Star ratings to keep, comma-separated (ratings=1,2)
          style: form
          explode: false
          schema:
            type: array
            minItems: 1
            maxItems: 5
            items: { type: integer, minimum: 1, maximum: 5 }
        - name: posted_from
          in: query
          description: Keep reviews posted at or after this ISO 8601 timestamp (offset required)
          schema: { type: string, format: date-time }
        - name: posted_before
          in: query
          description: Keep reviews posted before this ISO 8601 timestamp (offset required); must be after posted_from
          schema: { type: string, format: date-time }
        - name: app_version
          in: query
          description: Exact app version
          schema: { type: string, minLength: 1, maxLength: 100 }
      responses:
        "200":
          description: First page of the job's reviews
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ReviewPage" }
        "400": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
  /reviews:
    get:
      summary: Page through reviews
      description: Follow meta.next_cursor from any page until it is null.
      parameters:
        - name: cursor
          in: query
          required: true
          schema: { type: string }
        - name: page_size
          in: query
          schema: { type: integer, minimum: 1, maximum: 500, default: 100 }
      responses:
        "200":
          description: A page of reviews
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ReviewPage" }
        "400": { $ref: "#/components/responses/Error" }
        "429": { $ref: "#/components/responses/Error" }
  /usage:
    get:
      summary: Check your usage
      description: Plan, quota used and remaining, reset date, and today's refresh count. Never counts against your monthly quota.
      responses:
        "200":
          description: Current usage
          content:
            application/json:
              schema:
                type: object
                properties:
                  plan: { type: string }
                  period: { type: string, example: "2026-07" }
                  quota:
                    type: object
                    properties:
                      monthly_reviews: { type: integer }
                      used: { type: integer }
                      remaining: { type: integer }
                      resets_at: { type: string, format: date-time }
                  requests_this_month: { type: integer }
                  scrape_jobs_today: { type: integer }
                  scrape_jobs_daily_cap: { type: integer }
                  max_reviews_per_request: { type: integer }
                  rate_limit_rpm: { type: integer }
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: "API key: rk_live_..."
  responses:
    Error:
      description: Error envelope
      content:
        application/json:
          schema:
            type: object
            properties:
              error:
                type: object
                properties:
                  code:
                    type: string
                    enum: [UNAUTHORIZED, KEY_REVOKED, CLIENT_SUSPENDED, VALIDATION_ERROR, NOT_FOUND, RATE_LIMITED, QUOTA_EXCEEDED, SCRAPE_CAP_REACHED, INTERNAL]
                  message: { type: string }
                  status: { type: integer }
  schemas:
    AppResult:
      type: object
      properties:
        name: { type: string }
        developer: { type: string }
        icon: { type: string }
        rating: { type: number }
        stores:
          type: array
          items: { type: string }
        app_id:
          type: object
          properties:
            android: { type: string }
            ios: { type: string }
    Review:
      type: object
      properties:
        id: { type: string }
        store: { type: string, enum: [ios, android] }
        app_id: { type: string }
        country:
          type: string
          nullable: true
          description: >
            App Store: the storefront country. Google Play: the country of the
            collection that first stored the review, not where the reviewer is.
        lang: { type: string, nullable: true }
        rating: { type: integer, nullable: true }
        title: { type: string, nullable: true }
        body: { type: string, nullable: true }
        reviewer: { type: string, nullable: true }
        app_version: { type: string, nullable: true }
        developer_reply: { type: string, nullable: true }
        developer_reply_at: { type: string, format: date-time, nullable: true }
        url: { type: string, nullable: true }
        posted_at: { type: string, format: date-time, nullable: true }
    ReviewPage:
      type: object
      properties:
        data:
          type: array
          items: { $ref: "#/components/schemas/Review" }
        meta:
          type: object
          properties:
            delivered: { type: integer }
            reviews_charged:
              type: integer
              description: Reviews on this page delivered to your account for the first time in 30 days. The rest were free.
            next_cursor: { type: string, nullable: true }
            quota_remaining: { type: integer }
            quota_limited:
              type: boolean
              description: Present and true when the monthly quota ran out, so the page holds only reviews you received in the last 30 days.
    Job:
      type: object
      properties:
        id: { type: string, format: uuid }
        status: { type: string, enum: [queued, running, completed, failed] }
        progress: { type: integer }
        review_count: { type: integer, nullable: true }
        created_at: { type: string, format: date-time }
        completed_at: { type: string, format: date-time, nullable: true }
        error: { type: string, nullable: true }
        reviews_url: { type: string }
