openapi: 3.1.0
info:
  title: Siteline API
  version: 1.1.0
  description: |
    AI agent readiness scanner for public websites. Evaluates Signal, Navigate,
    Absorb, and Perform (SNAP) across any URL.

    Rate limiting conforms to the [Graceful Boundaries](https://github.com/snapsynapse/graceful-boundaries)
    specification at Level 4: structured refusal, proactive discovery, constructive
    guidance, and proactive RateLimit headers on success responses.
  contact:
    name: PAICE
    url: https://paice.work/
  license:
    name: Proprietary
    url: https://siteline.to/terms/

servers:
  - url: https://siteline.to
    description: Production

externalDocs:
  description: Full API context and integration guidance
  url: https://siteline.to/llms-full.txt

paths:
  /api/scan:
    get:
      operationId: scanUrl
      summary: Scan a public URL for agent readiness
      description: |
        Evaluates a public website across the SNAP rubric and returns a grade
        (A-F), score (0-100), per-pillar breakdown, findings, and remediation
        tier. A same-day result is reused only when its scanner and rubric
        versions match the current service versions; otherwise Siteline runs
        and stores a fresh scan for that URL scope and site-type hint.
        resultId is issued only after durable storage confirms the write.
        If persistence.stored is false, findings are usable but the report cannot
        be shared or emailed; retry the scan to save it.
      parameters:
        - name: url
          in: query
          required: true
          schema:
            type: string
          description: Public URL or domain to scan.
          example: https://example.com
        - name: siteType
          in: query
          required: false
          schema:
            type: string
            enum:
              - business
              - publishing
              - docs
              - software
              - data-reference
              - portfolio
              - research-education
              - transactional
              - community
              - authenticated-app
              - other
          description: >
            Optional site-family hint that focuses route discovery and result
            metadata. Stored as a hint, not score authority: scoring stays
            evidence-gated and an unrecognized value is ignored (the default
            business profile applies). Additional internal profile aliases exist
            but are not part of the stable public contract.
          example: docs
        - name: debug
          in: query
          required: false
          schema:
            type: string
            enum: [ "1", "true" ]
          description: Include fetch timing and debug details in the response.
        - name: force
          in: query
          required: false
          schema:
            type: string
            enum: [ "1", "true" ]
          description: |
            Operator-only cache bypass. Requests that set force require the
            X-Siteline-Bypass-Token header described by OperatorBypassToken.
            Ordinary scan requests are unauthenticated.
      responses:
        "200":
          description: "Completed scan, or a service deadline refusal with ok: false and
            error: scan_timeout. Consumers must inspect ok even when HTTP status
            is 200."
          headers:
            X-Siteline-Request-Id:
              $ref: "#/components/headers/X-Siteline-Request-Id"
            X-Siteline-Cache:
              $ref: "#/components/headers/X-Siteline-Cache"
            RateLimit:
              $ref: "#/components/headers/RateLimit"
            RateLimit-Policy:
              $ref: "#/components/headers/RateLimit-Policy"
            X-Siteline-Scan-Status:
              description: Present as timeout when the service deadline interrupts the scan.
              schema:
                type: string
                enum:
                  - timeout
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/ScanResult"
                  - $ref: "#/components/schemas/ScanTimeout"
        "400":
          description: Invalid or missing URL
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          description: Operator authorization is required for a forced scan
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          description: Rate limit exceeded
          headers:
            Retry-After:
              $ref: "#/components/headers/Retry-After"
            RateLimit:
              $ref: "#/components/headers/RateLimit"
            RateLimit-Policy:
              $ref: "#/components/headers/RateLimit-Policy"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RateLimitError"
        "500":
          description: Internal error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/result:
    get:
      operationId: getResult
      summary: Look up a stored scan result
      description: |
        Retrieves a previously stored scan result by result ID or domain.
        New result IDs follow `domain-slug-v2-identity-hash-YYYYMMDD`.
        Existing legacy result IDs remain readable.
      parameters:
        - name: id
          in: query
          required: true
          schema:
            type: string
          description: Result ID (e.g. example-com-20260322 or
            example-com-pricing-a1b2c3d4-20260322) or domain (e.g. example.com).
      responses:
        "200":
          description: Stored scan result
          headers:
            RateLimit:
              $ref: "#/components/headers/RateLimit"
            RateLimit-Policy:
              $ref: "#/components/headers/RateLimit-Policy"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ScanResult"
        "400":
          description: Missing id parameter
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: No result found for the given ID or domain
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Error"
                  - type: object
                    properties:
                      scanUrl:
                        type:
                          - string
                          - "null"
                        description: URL to trigger a fresh scan for this domain.
        "429":
          description: Rate limit exceeded
          headers:
            Retry-After:
              $ref: "#/components/headers/Retry-After"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RateLimitError"
        "503":
          description: Durable result storage unavailable
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/limits:
    get:
      operationId: getLimits
      summary: Rate limit and policy discovery
      description: |
        Returns all enforced rate limits, SSRF protection policy, DNS validation
        and connection-pinning behavior, response body byte caps, and links to relevant endpoints. Conforms to
        [Graceful Boundaries](https://github.com/snapsynapse/graceful-boundaries)
        Level 4.
      responses:
        "200":
          description: Policy document
          content:
            application/json:
              schema:
                type: object
                properties:
                  service:
                    type: string
                    example: Siteline
                  description:
                    type: string
                  conformance:
                    type: string
                    enum: [ not-applicable, none, level-1, level-2, level-3, level-4 ]
                    example: level-4
                  specUrl:
                    type: string
                    format: uri
                    example: https://github.com/snapsynapse/graceful-boundaries
                  limits:
                    type: object
                    additionalProperties:
                      type: object
                      properties:
                        endpoint:
                          type: string
                        method:
                          type: string
                        limits:
                          type: array
                          items:
                            type: object
                            properties:
                              type:
                                type: string
                              maxRequests:
                                type: integer
                              windowSeconds:
                                type: integer
                              description:
                                type: string
                        note:
                          type: string
                  ssrfProtection:
                    type: object
                    properties:
                      description:
                        type: string
                      blocked:
                        type: array
                        items:
                          type: string
                  responseHeaders:
                    type: object
                    additionalProperties:
                      type: string
                  links:
                    type: object
                    additionalProperties:
                      type: string

  /api/email-capture:
    post:
      operationId: captureEmail
      summary: Submit an email for scan report delivery
      description: |
        Accepts contact captures and stored-scan report requests. For
        source=pdf-export, result.resultId must identify a current stored scan;
        client-supplied report fields are ignored. Report delivery is accepted
        only after direct send or durable queue acceptance. Rate limited to 12
        submissions per IP per hour and 1 report request per IP per 60 seconds.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/EmailCaptureRequest"
            examples:
              storedReport:
                summary: Request a report from a stored scan
                value:
                  email: recipient@example.invalid
                  source: pdf-export
                  result:
                    resultId: example-com-a1b2c3d4-20260908
      responses:
        "200":
          description: Accepted through direct email send or webhook forwarding
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EmailCaptureAccepted"
        "202":
          description: Accepted durably through the email queue or capture store
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EmailCaptureAccepted"
        "400":
          description: Invalid email or missing report resultId
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Stored report result was not found or has expired
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          description: Rate limit exceeded
          headers:
            Retry-After:
              $ref: "#/components/headers/Retry-After"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RateLimitError"
        "502":
          description: Unexpected upstream delivery error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "503":
          description: Stored-result verification unavailable or no eligible acceptance
            path succeeded
          content:
            application/json:
              schema:
                anyOf:
                  - $ref: "#/components/schemas/Error"
                  - $ref: "#/components/schemas/EmailCaptureNotAccepted"
  /api/og:
    get:
      operationId: getOgImage
      summary: Generate OG grade card image
      description: Generates a 1200x630 branded grade card image for social sharing.
      parameters:
        - name: domain
          in: query
          schema:
            type: string
        - name: grade
          in: query
          schema:
            type: string
            enum:
              - A
              - B
              - C
              - D
              - F
        - name: score
          in: query
          schema:
            type: integer
            minimum: 0
            maximum: 100
        - name: resultId
          in: query
          schema:
            type: string
          description: "Alternative: auto-lookup from stored result."

components:
  securitySchemes:
    OperatorBypassToken:
      type: apiKey
      in: header
      name: X-Siteline-Bypass-Token
      description: Operator credential required only when force or refresh requests a
        cache bypass.
  headers:
    X-Siteline-Request-Id:
      description: Unique request identifier for debugging.
      schema:
        type: string
    X-Siteline-Cache:
      description: Cache status — HIT (in-memory), KV_HIT (durable store), or MISS
        (fresh scan).
      schema:
        type: string
        enum: [ HIT, KV_HIT, MISS ]
    RateLimit:
      description: |
        Proactive rate limit status per Graceful Boundaries Level 4.
        Format: `limit=N, remaining=X, reset=Y`
      schema:
        type: string
    RateLimit-Policy:
      description: |
        Rate limit policy per draft-ietf-httpapi-ratelimit-headers.
        Format: `N;w=WINDOW_SECONDS`
      schema:
        type: string
    Retry-After:
      description: Seconds until the rate limit resets. Present on 429 responses.
      schema:
        type: integer

  schemas:
    EmailCaptureRequest:
      type: object
      required: [ email, source ]
      properties:
        email:
          type: string
          format: email
        source:
          type: string
          description: Submission source. Use pdf-export for stored-scan report delivery.
        result:
          type: object
          properties:
            resultId:
              type: string
              description: Durable resultId returned by /api/scan.
        subscribe:
          type: boolean
        name:
          type: string
        message:
          type: string
      allOf:
        - if:
            properties:
              source:
                const: pdf-export
            required: [ source ]
          then:
            required: [ result ]
            properties:
              result:
                required: [ resultId ]
    EmailCaptureAccepted:
      type: object
      required:
        [
          ok,
          accepted,
          requestId,
          emailSent,
          queued,
          stored,
          forwarded,
          deliveryStatus,
          deliveryUncertain
        ]
      properties:
        ok:
          type: boolean
          const: true
        accepted:
          type: boolean
          const: true
        requestId:
          type: string
        emailSent:
          type: boolean
        queued:
          type: boolean
        stored:
          type: boolean
        forwarded:
          type: boolean
        deliveryStatus:
          type: string
          enum: [ sent, queued, forwarded, stored ]
        deliveryUncertain:
          type: boolean
        reminderStatus:
          type: string
          enum:
            - not-requested
            - scheduled
            - duplicate
            - failed
          description: Scheduling outcome for the optional rescan reminder, independent of
            report delivery.
    EmailCaptureNotAccepted:
      type: object
      required:
        [
          error,
          retryable,
          accepted,
          requestId,
          emailSent,
          queued,
          stored,
          forwarded,
          deliveryUncertain
        ]
      properties:
        error:
          type: string
          const: submission_not_accepted
        detail:
          type: string
        why:
          type: string
        retryable:
          type: boolean
          description: False when provider acceptance is uncertain, preventing a duplicate
            retry; true for confirmed total failure.
        accepted:
          type: boolean
          const: false
        requestId:
          type: string
        emailSent:
          type: boolean
        queued:
          type: boolean
        stored:
          type: boolean
        forwarded:
          type: boolean
        deliveryUncertain:
          type: boolean
    Error:
      type: object
      properties:
        error:
          type: string
        detail:
          type: string
        requestId:
          type: string
      required:
        - error
        - detail
    RateLimitError:
      type: object
      description: |
        Structured refusal per [Graceful Boundaries](https://github.com/snapsynapse/graceful-boundaries)
        Level 1+. Includes machine-parseable fields and human-readable explanation.
      properties:
        error:
          type: string
          example: rate_limit_exceeded
        detail:
          type: string
          example: You can run up to 10 scans per hour. Try again in 42 seconds.
        limit:
          type: string
          example: 10 scans per IP per hour
        retryAfterSeconds:
          type: integer
          example: 42
        why:
          type: string
          description: Security signal explaining the defensive intent behind this limit.
          example: Siteline is a free service. Rate limits keep it available for everyone
            and prevent abuse.
        humanUrl:
          type: string
          format: uri
          example: https://siteline.to/
        alternativeEndpoint:
          type: string
          description: Constructive guidance — a cached result may already exist.
        requestId:
          type: string
    ScanResult:
      $ref: schemas/scan-result.schema.json
    ScanTimeout:
      type: object
      required:
        - ok
        - error
        - detail
        - why
        - humanUrl
        - requestId
      properties:
        ok:
          type: boolean
          const: false
        error:
          type: string
          const: scan_timeout
        detail:
          type: string
        why:
          type: string
        humanUrl:
          type: string
          format: uri
        requestId:
          type: string
