openapi: 3.1.0
info:
  title: ASO Skill Developer API
  version: 1.0.0
  description: |
    Customer-facing API for App Store keyword search, difficulty, popularity,
    app metadata, credit balances, and credit purchases. Every successful data
    response costs one credit. Rejected requests and failed upstream calls do
    not consume a credit.

    Major versions are encoded in the URL. Existing `/v1/` operations do not
    receive intentional breaking changes; compatible additions may ship within
    v1, while breaking replacements use a new path such as `/v2/`. When
    practical, deprecated operations remain available for at least six months
    and return the RFC 9745 `Deprecation` header, a migration `Link` with
    `rel="deprecation"`, and an RFC 8594 `Sunset` header once removal is
    scheduled. See the developer guide for the complete policy.
  contact:
    name: ASO Skill support
    email: viktor@asosuite.com
  license:
    name: Proprietary; use governed by the ASO Skill Terms
    url: https://www.asoskill.com/terms
servers:
  - url: https://api.asoskill.com
    description: Production
externalDocs:
  description: Developer guide, including versioning and deprecation policy
  url: https://www.asoskill.com/developers#versioning
x-api-versioning:
  strategy: path
  currentMajorVersion: v1
  backwardCompatibleChangesWithinMajor: true
  minimumDeprecationPeriod: P6M
  policy: https://www.asoskill.com/developers#versioning
security:
  - bearerApiKey: []
tags:
  - name: Data
    description: Metered App Store keyword and app data endpoints
  - name: Credits
    description: API-key credit balance and usage
  - name: Billing
    description: Credit-pack discovery and Polar Checkout
  - name: Authentication
    description: Browser-assisted agent login and credential lifecycle
  - name: MCP
    description: OAuth-protected Model Context Protocol endpoint for AI clients
  - name: Operations
    description: Unauthenticated service availability
paths:
  /:
    get:
      tags: [Operations]
      security: []
      operationId: discoverApi
      summary: Discover the public ASO Skill API resources
      description: Returns stable links to the developer guide, OpenAPI contract, llms.txt, health endpoint, authentication guidance, and public endpoint summaries. This request does not cost a credit.
      responses:
        "200":
          description: Public API discovery document
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiDiscoveryResponse" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
  /health:
    get:
      tags: [Operations]
      security: []
      operationId: health
      summary: Check public API availability
      description: Returns a small, unauthenticated availability response. This request does not cost a credit.
      responses:
        "200":
          description: API Gateway and the health function are running
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/HealthResponse" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
  /mcp:
    post:
      tags: [MCP]
      security:
        - mcpOAuth: [data, credits]
      operationId: mcp
      summary: Call the hosted ASO Skill MCP server
      description: Streamable HTTP MCP endpoint for ChatGPT, Claude, and compatible clients. OAuth discovery is available through the standard protected-resource and authorization-server metadata endpoints. Individual data tools cost one credit on success; the balance tool is free.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: MCP JSON-RPC request or notification
      responses:
        "200":
          description: MCP JSON-RPC response
          content:
            application/json:
              schema: { type: object }
        "202": { description: MCP notification accepted }
        "401":
          description: OAuth connection is required or expired
          headers:
            WWW-Authenticate:
              schema: { type: string }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
  /v1/search:
    post:
      tags: [Data]
      operationId: search
      summary: Search the App Store and calculate keyword difficulty
      description: Returns ordered App Store results and a 0–100 difficulty score. Results are fresh for one hour; a stale fallback is never older than 24 hours. Optional app-detail enrichment may be omitted when Apple throttles it. A successful response costs one credit.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SearchRequest" }
      responses:
        "200":
          description: Search result; costs one credit
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SearchResponse" }
        "400": { $ref: "#/components/responses/InvalidRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/InsufficientCredits" }
        "409": { $ref: "#/components/responses/CreditConflict" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "502": { $ref: "#/components/responses/UpstreamFailure" }
        "503": { $ref: "#/components/responses/TemporarilyUnavailable" }
        "500": { $ref: "#/components/responses/InternalError" }
  /v1/popularity:
    post:
      tags: [Data]
      operationId: popularity
      summary: Return a keyword popularity score
      description: Returns a 1–100 popularity score. A successful response costs one credit.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PopularityRequest" }
      responses:
        "200":
          description: Popularity result; costs one credit
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PopularityResponse" }
        "400": { $ref: "#/components/responses/InvalidRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/InsufficientCredits" }
        "409": { $ref: "#/components/responses/CreditConflict" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "502": { $ref: "#/components/responses/UpstreamFailure" }
        "503": { $ref: "#/components/responses/TemporarilyUnavailable" }
        "500": { $ref: "#/components/responses/InternalError" }
  /v1/apps/lookup:
    post:
      tags: [Data]
      operationId: lookupApps
      summary: Look up detailed App Store metadata for up to 10 apps
      description: Returns platform-specific metadata for one to ten App Store IDs. A successful response costs one credit.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AppLookupRequest" }
      responses:
        "200":
          description: App details; costs one credit for the complete request
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AppLookupResponse" }
        "400": { $ref: "#/components/responses/InvalidRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/InsufficientCredits" }
        "409": { $ref: "#/components/responses/CreditConflict" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "502": { $ref: "#/components/responses/UpstreamFailure" }
        "503": { $ref: "#/components/responses/TemporarilyUnavailable" }
        "500": { $ref: "#/components/responses/InternalError" }
  /v1/credits:
    get:
      tags: [Credits]
      operationId: credits
      summary: Read the current credit balance
      description: Returns the authenticated account's available credit buckets and total. This request does not cost a credit.
      responses:
        "200":
          description: Current credit balance; does not cost a credit
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CreditsResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/AccountUnavailable" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
  /v1/billing/packs:
    get:
      tags: [Billing]
      security: []
      operationId: billingPacks
      summary: Discover available credit packs
      description: Returns the currently configured prepaid packs without creating a checkout. This public request does not cost a credit.
      responses:
        "200":
          description: Available packs, or billingEnabled false when online purchasing is unavailable
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BillingPacksResponse" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
  /v1/billing/checkout:
    post:
      tags: [Billing]
      operationId: checkout
      summary: Create a Polar-hosted credit checkout
      description: Returns a URL that the account owner must open to complete payment. Creating checkout does not cost a credit.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CheckoutRequest" }
      responses:
        "200":
          description: Manual action containing a short-lived Polar Checkout URL
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CheckoutResponse" }
        "400": { $ref: "#/components/responses/InvalidRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/AccountUnavailable" }
        "428": { $ref: "#/components/responses/LegalAcceptanceRequired" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/TemporarilyUnavailable" }
        "500": { $ref: "#/components/responses/InternalError" }
  /v1/auth/device:
    post:
      tags: [Authentication]
      security: []
      operationId: startDeviceAuthorization
      summary: Start browser-assisted agent login
      description: Creates a short-lived connection request. The agent opens the returned verification URL, the account owner signs in and approves the requested permissions, and the agent polls the token endpoint. This request does not cost a credit.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/DeviceAuthorizationRequest" }
      responses:
        "201":
          description: Short-lived browser authorization request
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DeviceAuthorizationResponse" }
        "400": { $ref: "#/components/responses/InvalidRequest" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
  /v1/auth/device/token:
    post:
      tags: [Authentication]
      security: []
      operationId: exchangeDeviceAuthorization
      summary: Poll and exchange an approved agent login
      description: Returns authorization_pending until the account owner acts. After approval, returns a retry-safe encrypted API-key envelope that only the initiating client can decrypt. This request does not cost a credit.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/DeviceTokenRequest" }
      responses:
        "200":
          description: Encrypted API credential and metadata
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DeviceTokenResponse" }
        "400": { $ref: "#/components/responses/InvalidRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/AuthorizationDenied" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
  /v1/auth/credential:
    delete:
      tags: [Authentication]
      operationId: revokeCurrentCredential
      summary: Revoke the calling API key
      description: Revokes the bearer API key used for this request. Revocation can take up to five minutes to propagate through the authorizer cache. This request does not cost a credit.
      responses:
        "200":
          description: Credential revoked or already revoked
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RevokeCredentialResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/AccountUnavailable" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
components:
  securitySchemes:
    bearerApiKey:
      type: http
      scheme: bearer
      bearerFormat: aso_live_...
      description: "Long-lived secret created in the ASO Skill developer dashboard. Send it as `Authorization: Bearer <key>` and keep it on the server."
    mcpOAuth:
      type: oauth2
      description: OAuth authorization code with S256 PKCE, dynamic client registration, exact MCP resource binding, refresh rotation, and revocation.
      flows:
        authorizationCode:
          authorizationUrl: https://api.asoskill.com/oauth/authorize
          tokenUrl: https://api.asoskill.com/oauth/token
          refreshUrl: https://api.asoskill.com/oauth/token
          scopes:
            data: Search, popularity, and app metadata tools
            credits: Read the connected account credit balance
  headers:
    RateLimitPolicy:
      description: API Gateway request policy for the route. This is a service-capacity hint, not a credit balance or per-account quota.
      schema:
        type: string
        example: '"default";q=50;w=1'
    RateLimitLimit:
      description: Compatibility field advertising the same maximum request rate.
      schema:
        type: integer
        example: 50
    RetryAfter:
      description: Number of seconds to wait before retrying. When absent, use exponential backoff with jitter.
      schema:
        type: integer
        minimum: 1
  responses:
    InvalidRequest:
      description: The request body or parameters are invalid; no credit is consumed
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
    Unauthorized:
      description: The bearer credential is missing, invalid, expired, or revoked; no credit is consumed
      content:
        application/json:
          schema:
            oneOf:
              - { $ref: "#/components/schemas/ErrorResponse" }
              - { $ref: "#/components/schemas/GatewayMessage" }
    AccountUnavailable:
      description: The account is unavailable or disabled; no credit is consumed
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
    AuthorizationDenied:
      description: The account owner denied the connection request; no credit is consumed
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
    NotFound:
      description: The requested resource was not found; no credit is consumed
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
    InsufficientCredits:
      description: No promotional or paid credits remain; no credit is consumed
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
    CreditConflict:
      description: A concurrent credit reservation could not be completed; no credit is consumed and the request may be retried
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
    LegalAcceptanceRequired:
      description: The current Terms and Privacy Policy must be reviewed before purchasing credits
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
    RateLimited:
      description: Request capacity or a rate allowance was exceeded; no credit is consumed. Respect Retry-After when present, otherwise retry with exponential backoff and jitter.
      headers:
        Retry-After: { $ref: "#/components/headers/RetryAfter" }
      content:
        application/json:
          schema:
            oneOf:
              - { $ref: "#/components/schemas/ErrorResponse" }
              - { $ref: "#/components/schemas/GatewayMessage" }
    UpstreamFailure:
      description: An upstream App Store service failed; any reserved credit is restored
      headers:
        Retry-After: { $ref: "#/components/headers/RetryAfter" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
    TemporarilyUnavailable:
      description: ASO Skill or an upstream service is temporarily unavailable; any reserved credit is restored. Retry after the indicated delay when provided.
      headers:
        Retry-After: { $ref: "#/components/headers/RetryAfter" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
    InternalError:
      description: Unexpected processing failure; any reserved credit is restored
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
  schemas:
    ErrorResponse:
      type: object
      required: [error]
      additionalProperties: false
      properties:
        error: { $ref: "#/components/schemas/ApiError" }
    ApiError:
      type: object
      description: Machine-readable error details. Clients should branch on code and present message when useful; additional fields provide error-specific recovery context.
      required: [code, message]
      properties:
        code:
          type: string
          description: Stable snake_case identifier suitable for programmatic handling
          example: invalid_request
        message:
          type: string
          description: Human-readable explanation that is safe to show to the account owner
          example: The request body is invalid
        retryAfterSeconds:
          type: integer
          minimum: 1
          description: Suggested delay for a retryable failure, when applicable
      additionalProperties: true
    GatewayMessage:
      type: object
      required: [message]
      properties:
        message: { type: string, example: Too Many Requests }
    HealthResponse:
      type: object
      required: [ok, service, version]
      properties:
        ok: { type: boolean, const: true }
        service: { type: string, const: aso-skill }
        version: { type: string, const: v1 }
    ApiDiscoveryEndpoint:
      type: object
      required: [method, path, creditsOnSuccess]
      properties:
        method: { type: string, enum: [GET, POST] }
        path: { type: string }
        creditsOnSuccess: { type: integer, minimum: 0, maximum: 1 }
        authenticationRequired: { type: boolean, default: true }
    ApiDiscoveryResponse:
      type: object
      required: [name, service, version, description, documentation, openapi, llms, health, mcp, versioning, authentication, endpoints]
      properties:
        name: { type: string, const: ASO Skill Developer API }
        service: { type: string, const: aso-skill }
        version: { type: string, const: v1 }
        description: { type: string }
        documentation: { type: string, format: uri }
        openapi: { type: string, format: uri }
        llms: { type: string, format: uri }
        health: { type: string, format: uri }
        mcp:
          type: object
          required: [transport, url, documentation, authentication]
          properties:
            transport: { type: string, const: streamable-http }
            url: { type: string, format: uri }
            documentation: { type: string, format: uri }
            authentication: { type: string, const: oauth }
        versioning:
          type: object
          required: [current, strategy, policy]
          additionalProperties: false
          properties:
            current: { type: string, const: v1 }
            strategy: { type: string }
            policy: { type: string, format: uri }
        authentication:
          type: object
          required: [type, createApiKeyAt, guidance]
          properties:
            type: { type: string, const: http-bearer }
            createApiKeyAt: { type: string, format: uri }
            guidance: { type: string }
            agentLogin:
              type: object
              required: [method, path, verificationUrl]
              properties:
                method: { type: string, const: POST }
                path: { type: string, const: /v1/auth/device }
                verificationUrl: { type: string, format: uri }
        endpoints:
          type: object
          required: [search, popularity, appLookup, credits, billingPacks]
          properties:
            search: { $ref: "#/components/schemas/ApiDiscoveryEndpoint" }
            popularity: { $ref: "#/components/schemas/ApiDiscoveryEndpoint" }
            appLookup: { $ref: "#/components/schemas/ApiDiscoveryEndpoint" }
            credits: { $ref: "#/components/schemas/ApiDiscoveryEndpoint" }
            billingPacks: { $ref: "#/components/schemas/ApiDiscoveryEndpoint" }
            agentLogin: { $ref: "#/components/schemas/ApiDiscoveryEndpoint" }
    Storefront:
      type: string
      pattern: "^[A-Za-z]{2}$"
      default: US
      description: Two-letter App Store storefront code; normalized to uppercase
    Platform:
      type: string
      enum: [iphone, ipad, mac, appletv, watch, vision]
      default: iphone
    CacheStatus:
      type: string
      enum: [hit, refresh, stale]
    SearchRequest:
      type: object
      required: [term]
      additionalProperties: false
      properties:
        term: { type: string, minLength: 1, maxLength: 100 }
        storefront: { $ref: "#/components/schemas/Storefront" }
        platform: { $ref: "#/components/schemas/Platform" }
    SearchResult:
      type: object
      required: [position, appId, name]
      properties:
        position: { type: integer, minimum: 1 }
        appId: { type: string }
        name: { type: string }
        subtitle: { type: string }
        developer: { type: string }
        rating: { type: number }
        ratingCount: { type: integer, minimum: 0 }
        categoryRank: { type: integer, minimum: 1 }
        category: { type: string }
        url: { type: string, format: uri }
        icon: { type: string, format: uri }
    SearchResponse:
      type: object
      required: [term, storefront, platform, difficulty, resultCount, results, cache, fetchedAt, requestId]
      properties:
        term: { type: string }
        storefront: { type: string }
        platform: { $ref: "#/components/schemas/Platform" }
        difficulty: { type: integer, minimum: 0, maximum: 100 }
        resultCount: { type: integer, minimum: 0 }
        results:
          type: array
          items: { $ref: "#/components/schemas/SearchResult" }
        cache: { $ref: "#/components/schemas/CacheStatus" }
        fetchedAt: { type: string, format: date-time }
        requestId: { type: string }
    PopularityRequest:
      type: object
      required: [term]
      additionalProperties: false
      properties:
        term: { type: string, minLength: 1, maxLength: 100 }
        storefront: { $ref: "#/components/schemas/Storefront" }
    PopularityResponse:
      type: object
      required: [term, storefront, score, source, cache, fetchedAt, requestId]
      properties:
        term: { type: string }
        storefront: { type: string }
        score: { type: integer, minimum: 1, maximum: 100 }
        source: { type: string, enum: [monthly, direct, related, fallback] }
        cache: { $ref: "#/components/schemas/CacheStatus" }
        fetchedAt: { type: string, format: date-time }
        requestId: { type: string }
    AppLookupRequest:
      type: object
      required: [appIds]
      additionalProperties: false
      properties:
        appIds:
          type: array
          minItems: 1
          maxItems: 10
          uniqueItems: true
          items: { type: string, pattern: "^\\d+$" }
        storefront: { $ref: "#/components/schemas/Storefront" }
        platform: { $ref: "#/components/schemas/Platform" }
    AppRating:
      type: object
      required: [average, count, one, two, three, four, five]
      properties:
        average: { type: number }
        count: { type: integer, minimum: 0 }
        one: { type: integer, minimum: 0 }
        two: { type: integer, minimum: 0 }
        three: { type: integer, minimum: 0 }
        four: { type: integer, minimum: 0 }
        five: { type: integer, minimum: 0 }
    CategoryRanking:
      type: object
      required: [chart, category, position]
      properties:
        chart: { type: string }
        category: { type: string }
        position: { type: integer, minimum: 1 }
    VersionHistoryItem:
      type: object
      required: [versionDisplay, releaseTimestamp]
      properties:
        versionDisplay: { type: string }
        releaseTimestamp: { type: string, format: date-time }
    AppDetails:
      type: object
      required: [appId, developer, iconUrl, name, subtitle, categories, rating, categoryRanking, currentVersionNumber, currentVersionReleasedAt, versionHistory, screenshots, description, paid]
      properties:
        appId: { type: string }
        developer: { type: string }
        iconUrl: { type: string, format: uri }
        name: { type: string }
        subtitle: { type: [string, "null"] }
        categories:
          type: array
          items: { type: string }
        rating: { $ref: "#/components/schemas/AppRating" }
        categoryRanking:
          oneOf:
            - { $ref: "#/components/schemas/CategoryRanking" }
            - { type: "null" }
        currentVersionNumber: { type: string }
        currentVersionReleasedAt: { type: string, format: date-time }
        versionHistory:
          type: array
          items: { $ref: "#/components/schemas/VersionHistoryItem" }
        screenshots:
          type: array
          items: { type: string, format: uri }
        description: { type: string }
        paid: { type: boolean }
    AppLookupResponse:
      type: object
      required: [storefront, platform, apps, missingAppIds, cache, fetchedAt, requestId]
      properties:
        storefront: { type: string }
        platform: { $ref: "#/components/schemas/Platform" }
        apps:
          type: array
          items: { $ref: "#/components/schemas/AppDetails" }
        missingAppIds:
          type: array
          items: { type: string }
        cache: { $ref: "#/components/schemas/CacheStatus" }
        fetchedAt: { type: string, format: date-time }
        requestId: { type: string }
    CreditBalance:
      type: object
      required: [paid, promotional, dailyFree, total, used, dailyFreeResetsAt]
      properties:
        paid: { type: integer, minimum: 0 }
        promotional: { type: integer, minimum: 0 }
        dailyFree: { type: integer, minimum: 0 }
        total: { type: integer, minimum: 0 }
        used: { type: integer, minimum: 0 }
        dailyFreeResetsAt: { type: string, format: date-time }
    CreditsResponse:
      type: object
      required: [credits, topUp, requestId]
      properties:
        credits: { $ref: "#/components/schemas/CreditBalance" }
        topUp:
          type: object
          required: [method, path]
          properties:
            method: { type: string, const: GET }
            path: { type: string, const: /v1/billing/packs }
        requestId: { type: string }
    BillingPack:
      type: object
      required: [packId, credits, amountCents, currency, taxBehavior]
      properties:
        packId: { type: string, pattern: "^[a-z][a-z0-9_]{2,63}$" }
        credits: { type: integer, minimum: 1 }
        amountCents: { type: integer, minimum: 1 }
        currency: { type: string, pattern: "^[a-z]{3}$" }
        taxBehavior: { type: string, const: exclusive }
    CheckoutDiscovery:
      type: object
      required: [method, path, body]
      properties:
        method: { type: string, const: POST }
        path: { type: string, const: /v1/billing/checkout }
        body:
          type: object
          required: [packId]
          properties:
            packId: { type: string }
    BillingPacksResponse:
      type: object
      required: [billingEnabled, unit, creditsPerDataCall, pricesIncludeTax, packs, checkout]
      properties:
        billingEnabled: { type: boolean }
        unit: { type: string, const: credit }
        creditsPerDataCall: { type: integer, const: 1 }
        pricesIncludeTax: { type: boolean, const: false }
        packs:
          type: array
          items: { $ref: "#/components/schemas/BillingPack" }
        checkout:
          oneOf:
            - { $ref: "#/components/schemas/CheckoutDiscovery" }
            - { type: "null" }
    CheckoutRequest:
      type: object
      required: [packId]
      additionalProperties: false
      properties:
        packId: { type: string, pattern: "^[a-z][a-z0-9_]{2,63}$" }
    CheckoutResponse:
      type: object
      required: [status, action, requestId]
      properties:
        status: { type: string, const: action_required }
        action:
          type: object
          required: [type, url, expiresAt, message]
          properties:
            type: { type: string, const: open_url }
            url: { type: [string, "null"], format: uri }
            expiresAt: { type: integer }
            message: { type: string }
        requestId: { type: string }
    ApiKeyScope:
      type: string
      enum: [data, credits, checkout]
    DeviceAuthorizationRequest:
      type: object
      required: [codeChallenge, name, scopes]
      additionalProperties: false
      properties:
        codeChallenge: { type: string, pattern: "^[A-Za-z0-9_-]{43}$" }
        name: { type: string, minLength: 1, maxLength: 80 }
        scopes:
          type: array
          minItems: 1
          maxItems: 3
          uniqueItems: true
          items: { $ref: "#/components/schemas/ApiKeyScope" }
        expiresAt: { type: [string, "null"], format: date-time }
    DeviceAuthorizationResponse:
      type: object
      required: [deviceCode, userCode, verificationUri, verificationUriComplete, expiresIn, interval]
      properties:
        deviceCode: { type: string, description: High-entropy secret retained only by the initiating client }
        userCode: { type: string, pattern: "^[23456789ABCDEFGHJKLMNPQRSTUVWXYZ]{4}-[23456789ABCDEFGHJKLMNPQRSTUVWXYZ]{4}$" }
        verificationUri: { type: string, format: uri }
        verificationUriComplete: { type: string, format: uri }
        expiresIn: { type: integer, minimum: 1 }
        interval: { type: integer, minimum: 1 }
    DeviceTokenRequest:
      type: object
      required: [deviceCode, codeVerifier]
      additionalProperties: false
      properties:
        deviceCode: { type: string, minLength: 32, maxLength: 256 }
        codeVerifier: { type: string, pattern: "^[A-Za-z0-9_-]{43,128}$" }
    CredentialEnvelope:
      type: object
      required: [algorithm, iv, ciphertext, tag]
      additionalProperties: false
      properties:
        algorithm: { type: string, const: A256GCM }
        iv: { type: string }
        ciphertext: { type: string }
        tag: { type: string }
    DeviceTokenResponse:
      type: object
      required: [tokenType, credential, apiKey]
      properties:
        tokenType: { type: string, const: Bearer }
        credential: { $ref: "#/components/schemas/CredentialEnvelope" }
        apiKey:
          type: object
          required: [id, name, scopes, expiresAt]
          properties:
            id: { type: string, format: uuid }
            name: { type: string }
            scopes:
              type: array
              items: { $ref: "#/components/schemas/ApiKeyScope" }
            expiresAt: { type: [string, "null"], format: date-time }
    RevokeCredentialResponse:
      type: object
      required: [id, status]
      properties:
        id: { type: string, format: uuid }
        status: { type: string, const: revoked }
        revokedAt: { type: string, format: date-time }
