openapi: 3.0.3
info:
  title: Abundera QR Check API
  version: 1.0.0
  description: QR / barcode safety classification service.
  contact:
    name: Abundera
    email: support@abundera.ai
servers:
- url: https://check.qr.abundera.ai
  description: Production
paths:
  /.well-known/abundera-capabilities.json:
    get:
      tags:
      - .well-known
      summary: Fetch Abundera Check product capabilities document
      description: 'JSON document describing the check.qr.abundera.ai API surface, OAuth-style

        scopes, and rate-limit tiers. Follows RFC 8615 (well-known URI) and is

        aggregated by abundera.ai''s federated /account/api-keys UI.


        Consumers must ignore unknown fields (RFC 8259 §4). Minor schema_version

        bumps are backward-compatible.


        Public; no authentication required. CORS-open so cross-origin aggregators

        and integrators can fetch directly.


        Caching: `Cache-Control: public, max-age=60, stale-while-revalidate=600`.

        A weak ETag (first 32 hex of body SHA-256) supports `If-None-Match` 304s.

        '
      responses:
        '200':
          description: Capabilities document
          content:
            application/json:
              schema:
                type: object
                properties:
                  $schema:
                    type: string
                  schema_version:
                    type: string
                  product:
                    type: object
                  api:
                    type: object
                  auth:
                    type: object
                  rate_limits:
                    type: object
                additionalProperties: false
        '304':
          description: Not modified (ETag matched)
        '404':
          description: Error
          content:
            application/json:
              schema:
                allOf:
                - $ref: '#/components/schemas/ErrorEnvelope'
                - properties:
                    error:
                      properties:
                        retry_after:
                          type: integer
                          description: Seconds to wait before retry. Present on 429.
      operationId: getWellKnownAbunderaCapabilitiesJson
  /.well-known/gpc.json:
    get:
      tags:
      - .well-known
      summary: known/gpc.json
      description: 'Global Privacy Control signal acknowledgement. Declares that this

        site honors the GPC header (Sec-GPC: 1) as a valid opt-out of sale

        and sharing of personal information under CCPA/CPRA. Format per

        https://privacycg.github.io/gpc-spec/.'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required:
                - ok
                properties:
                  ok:
                    type: boolean
                    description: True when the request succeeded.
                additionalProperties: false
        '429':
          description: Rate limited
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '404':
          description: Error
          content:
            application/json:
              schema:
                allOf:
                - $ref: '#/components/schemas/ErrorEnvelope'
                - properties:
                    error:
                      properties:
                        retry_after:
                          type: integer
                          description: Seconds to wait before retry. Present on 429.
      operationId: getWellKnownGpcJson
  /.well-known/llms.txt:
    get:
      tags:
      - .well-known
      summary: known/llms.txt
      description: 'Permanent alias to /llms.txt. Some LLM crawlers probe the well-known

        location before the root; this 301 keeps a single canonical file.

        See ~/projects/siteops/docs/LLMS-TXT-STANDARD.md.'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required:
                - ok
                properties:
                  ok:
                    type: boolean
                    description: True when the request succeeded.
                additionalProperties: false
        '429':
          description: Rate limited
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '404':
          description: Error
          content:
            application/json:
              schema:
                allOf:
                - $ref: '#/components/schemas/ErrorEnvelope'
                - properties:
                    error:
                      properties:
                        retry_after:
                          type: integer
                          description: Seconds to wait before retry. Present on 429.
      operationId: getWellKnownLlmsTxt
  /.well-known/qr-safety-policy.json:
    get:
      tags:
      - .well-known
      summary: policy.json
      description: 'Family-canonical declaration of how this Abundera Check instance

        classifies QR / barcode payload safety. Lets external integrators

        (browsers, MDMs, mail filters, sister-product safety pipelines)

        consume verdicts knowing exactly what the verdict schema means.


        Pair with /.well-known/qr-redirect-policy.json on qr.abundera.ai ,

        one declares "how I mint redirects", the other declares "how I

        classify them."


        Schema: ~/projects/siteops/docs/WELL-KNOWN-CATALOG.md'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required:
                - ok
                properties:
                  ok:
                    type: boolean
                    description: True when the request succeeded.
                additionalProperties: false
        '304':
          description: Not modified
        '429':
          description: Rate limited
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '404':
          description: Error
          content:
            application/json:
              schema:
                allOf:
                - $ref: '#/components/schemas/ErrorEnvelope'
                - properties:
                    error:
                      properties:
                        retry_after:
                          type: integer
                          description: Seconds to wait before retry. Present on 429.
      operationId: getWellKnownQrSafetyPolicyJson
  /api/billing/session:
    get:
      tags:
      - billing
      summary: Read the sanitized result of a founding-member checkout
      description: 'Sanitized read of a paid Stripe Checkout Session for the post-checkout success modal. Public: the session
        id is the credential (Stripe-issued, single-use, one-hour replay window). Only sessions created by this product that
        are paid answer 200; every other case answers 404 so a probe learns nothing about other products'' sessions.

        '
      parameters:
      - name: session_id
        in: query
        required: true
        description: Stripe Checkout Session id from the success URL.
        schema:
          type: string
          pattern: ^cs_
          maxLength: 255
      responses:
        '200':
          description: The paid session, reduced to what the modal shows
          content:
            application/json:
              schema:
                type: object
                required:
                - status
                - cohort
                - plan_label
                - price_label
                - customer_name
                properties:
                  status:
                    type: string
                    description: Stripe session status
                  cohort:
                    type: string
                    nullable: true
                    description: Checkout metadata type, for example founding_member
                  plan_label:
                    type: string
                  price_label:
                    type: string
                    description: Amount actually paid, for example $39.00 USD/yr
                  customer_name:
                    type: string
                    nullable: true
                additionalProperties: false
        '400':
          description: session_id missing or not a Checkout Session id
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '404':
          description: No matching paid session inside the replay window
          content:
            application/json:
              schema:
                allOf:
                - $ref: '#/components/schemas/ErrorEnvelope'
                - properties:
                    error:
                      properties:
                        retry_after:
                          type: integer
                          description: Seconds to wait before retry. Present on 429.
        '429':
          description: Rate limited
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '500':
          description: Stripe returned an unreadable response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '503':
          description: Stripe is not configured in this environment
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
      operationId: getBillingSession
  /api/coverage:
    get:
      tags:
      - coverage
      summary: Public QR Type Catalog manifest
      description: 'Returns the live QR Type Catalog grouped by category, with status counts and credential-risk highlights.
        Powers the public /coverage/ page and is stable as a JSON API for third-party docs. Single source of truth is `~/projects/abundera-shared/data/qr-type-catalog.json`.
        Cached at the edge for 1 hour.

        '
      responses:
        '200':
          description: Coverage manifest
          content:
            application/json:
              schema:
                type: object
                required:
                - catalog_version
                - totals
                - categories
                - types
                properties:
                  catalog_version:
                    type: string
                    description: ISO date (YYYY-MM-DD)
                  totals:
                    type: object
                    properties:
                      total:
                        type: integer
                      shipped:
                        type: integer
                      partial:
                        type: integer
                      backlog:
                        type: integer
                      credential_risk:
                        type: integer
                    additionalProperties: false
                  categories:
                    type: object
                    additionalProperties:
                      type: integer
                  national_rollouts:
                    type: object
                    additionalProperties:
                      type: integer
                  types:
                    type: array
                    items:
                      type: object
                      required:
                      - id
                      - category
                      - human_name
                      - status
                      properties:
                        id:
                          type: string
                        category:
                          type: string
                        subcategory:
                          type: string
                        human_name:
                          type: string
                        summary:
                          type: string
                        status:
                          type: string
                          enum:
                          - shipped
                          - partial
                          - backlog
                        credential_risk:
                          type: boolean
                        scanner_supported:
                          type: boolean
                        generator_supported:
                          type: boolean
                        patents:
                          type: array
                          items:
                            type: string
                      additionalProperties: false
                additionalProperties: false
        '404':
          description: Error
          content:
            application/json:
              schema:
                allOf:
                - $ref: '#/components/schemas/ErrorEnvelope'
                - properties:
                    error:
                      properties:
                        retry_after:
                          type: integer
                          description: Seconds to wait before retry. Present on 429.
      operationId: getCoverage
  /api/scan:
    post:
      tags:
      - scan
      summary: Classify a decoded QR / barcode payload
      description: 'Production embodiment of patents QR-17 (multi-hop redirect-chain mutability) and QR-18 (multi-modal payload
        threat classification). Public endpoint, no auth. Anonymous tier rate-limited to 10/min burst AND 3/24h per IP (both
        gates; daily is strictest). No Turnstile. Raw payload is never persisted; verdict cache is keyed by SHA-256(payload_type
        || discriminator || server_salt).

        '
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - payload
              additionalProperties: false
              properties:
                payload:
                  type: string
                  maxLength: 4096
                  description: Decoded payload text (URL, vCard, WiFi config, etc.)
                client_decoded_type:
                  type: string
                  maxLength: 64
                  description: Optional client-side hint about payload kind.
                decoder_format:
                  type: string
                  description: Symbology the browser-side decoder reported. Same ids as DECODER_FORMATS in this handler and
                    SCAN_FORMATS in site/js/scan-decoder.js.
                  enum:
                  - qr_code
                  - aztec
                  - data_matrix
                  - pdf417
                  - code_128
                  - code_39
                  - code_93
                  - codabar
                  - ean_13
                  - ean_8
                  - itf
                  - upc_a
                  - upc_e
                  - maxicode
                  - gs1_databar
                  - gs1_databar_expanded
                decoder_source:
                  type: string
                  enum:
                  - camera
                  - upload
                  - paste
                  - type
                  - deep_link
                  - vision_model
                embedded_logo_hint:
                  type: object
                  description: Optional. QR-22-CIP browser-side classifier output for the QR's embedded brand logo. Cross-checked
                    server-side against brand-list.json canonical_host vs. the resolved URL host. Ignored unless EMBEDDED_LOGO_DETECTION_ENABLED
                    is on.
                  properties:
                    brand_label:
                      type: string
                      maxLength: 64
                    confidence:
                      type: number
                      minimum: 0
                      maximum: 1
                    model_version:
                      type: string
                      maxLength: 64
      responses:
        '200':
          description: Verdict
          content:
            application/json:
              schema:
                type: object
                required:
                - ok
                - payload_type
                - threat_class
                - findings
                - schema_version
                properties:
                  ok:
                    type: boolean
                    enum:
                    - true
                  payload_type:
                    type: string
                  subtype:
                    type: string
                  threat_class:
                    type: string
                    enum:
                    - likely_safe
                    - suspicious
                    - likely_dangerous
                    - hard_blocked
                    - unverifiable
                  mutability:
                    type: string
                    enum:
                    - immutable
                    - dynamic
                    - redirector
                    - unknown
                  attribution:
                    type: object
                  chain:
                    type: array
                    items:
                      type: object
                  findings:
                    type: array
                    items:
                      type: object
                  sub_payloads:
                    type: array
                    items:
                      type: object
                  disclosure:
                    type: string
                  latency_ms:
                    type: integer
                  payload_preview:
                    type: string
                    description: First 160 characters of the caller's own payload, computed per request and never written
                      to the verdict cache
                  schema_version:
                    type: integer
                    enum:
                    - 1
                  decoder_format:
                    type: string
                  decoder_source:
                    type: string
                  visitor:
                    type: object
                  learn_more_url:
                    type: string
                    format: uri
                  info_snippet:
                    type: string
                  feedback_token:
                    type: string
                    nullable: true
                    description: Opaque proof-of-scan token bound to this payload's hash and threat_class. Required by POST
                      /api/v1/feedback and POST /api/v1/mutation-alerts/subscribe for the same payload. Expires after 24h;
                      single-use once spent on /api/v1/feedback. null only when the server has no MUTATION_ALERT_KEY configured.
                additionalProperties: false
        '400':
          description: Invalid JSON or schema validation failure
          content:
            application/json:
              schema:
                allOf:
                - $ref: '#/components/schemas/ErrorEnvelope'
                - properties:
                    error:
                      properties:
                        code:
                          type: string
                          enum:
                          - invalid_json
                          - validation
        '429':
          description: Rate limited
          content:
            application/json:
              schema:
                allOf:
                - $ref: '#/components/schemas/ErrorEnvelope'
                - properties:
                    error:
                      properties:
                        code:
                          type: string
                          enum:
                          - rate_limited
                        retry_after:
                          type: integer
                          description: Seconds until the limit resets.
        '500':
          description: Server error
          content:
            application/json:
              schema:
                allOf:
                - $ref: '#/components/schemas/ErrorEnvelope'
                - properties:
                    error:
                      properties:
                        code:
                          type: string
                          enum:
                          - config_error
                          - internal_error
                        request_id:
                          type: string
                          description: Correlation id for the server log entry. Present on internal_error.
      operationId: postScan
  /api/v1/auth/whoami:
    get:
      tags:
      - auth
      summary: RBAC introspection for the current credential
      description: 'Side-effect-free. Returns `{ authenticated, product, user_id, email,

        name, auth_source, role, tier, scopes, key, rate_limit }` for the

        presented JWT/API key, or `{ authenticated:false, reason }` (200)

        when absent/invalid. Never cached.

        '
      security:
      - bearerAuth: []
      responses:
        '200':
          description: Introspection result
          content:
            application/json:
              schema:
                type: object
                properties:
                  authenticated:
                    type: boolean
                  product:
                    type: string
                  user_id:
                    type: string
                    nullable: true
                  email:
                    type: string
                    nullable: true
                  auth_source:
                    type: string
                    nullable: true
                    enum:
                    - api_key
                    - jwt
                    - session
                  role:
                    type: string
                    nullable: true
                  tier:
                    type: string
                    nullable: true
                  scopes:
                    type: array
                    items:
                      type: string
                  key:
                    type: object
                    nullable: true
                  rate_limit:
                    type: object
                    nullable: true
        '404':
          description: Error
          content:
            application/json:
              schema:
                allOf:
                - $ref: '#/components/schemas/ErrorEnvelope'
                - properties:
                    error:
                      properties:
                        retry_after:
                          type: integer
                          description: Seconds to wait before retry. Present on 429.
      operationId: getV1AuthWhoami
  /api/v1/feedback:
    post:
      tags:
      - feedback
      summary: Submit feedback on a scan verdict
      description: 'Collects user feedback on a verdict produced by the scan API. No authentication

        is required. The scanned payload is never stored, only a SHA-256 hash is kept,

        matching the privacy posture of the verdict cache (QR-17/18 §6.4.x).


        Requires `feedback_token`, the proof-of-scan token POST /api/scan minted on the

        verdict this report is about (functions/lib/feedback-token.js). The token must

        verify, be unexpired (24h), match this request''s `payload_hash` and

        `verdict_class`, and not have been spent before (single-use). Without it (or with

        a bad one) the report is rejected: this closes the gap where any 64-hex

        payload_hash plus free-text verdict_class was accepted with no proof the caller

        ever scanned anything.


        Each report is stored in the RATE_LIMIT_KV namespace under a key of the form

        `feedback:<ts>:<hash-prefix>` with a 90-day TTL. No D1 dependency.


        Feedback is advisory only (docs/FEEDBACK-POLICY.md): it never changes a verdict,

        a cache entry, or a warning automatically. Each accepted report also updates a

        per-payload aggregate (functions/lib/feedback-aggregate.js, `fbagg:<payload_hash>`)

        counting one vote per source per day, a source being a daily-rotating HMAC of the

        caller''s IP network prefix and ASN. A repeat report from the same source on the

        same payload the same day still answers 200 with a `report_id` (the dedupe is

        never revealed) but writes no second `feedback:` record. Operators read the

        aggregate via `GET /api/v1/ops/feedback?view=aggregate`.


        Rate-limited to 10 requests per hour per IP address. Operator IPs (as configured

        in the environment) bypass the rate limit. If the KV binding or MUTATION_ALERT_KEY

        is unavailable the endpoint returns 503 (fails closed; the token cannot be

        verified without the key). Rate-limit failures are non-fatal and fail open so

        legitimate feedback is not blocked.


        Requires `turnstile_token` (Cloudflare Turnstile, execute mode) ONLY when

        TURNSTILE_SECRET is configured in the environment; while unset the check is

        skipped entirely (no widget exists yet). See functions/lib/turnstile-gate.js.

        '
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - payload_hash
              - verdict_class
              - reason_class
              - feedback_token
              properties:
                payload_hash:
                  type: string
                  minLength: 64
                  maxLength: 64
                  pattern: ^[0-9a-fA-F]{64}$
                  description: SHA-256 hex digest of the originally scanned payload. Must match the hash bound into feedback_token.
                verdict_class:
                  type: string
                  maxLength: 32
                  enum:
                  - likely_safe
                  - suspicious
                  - likely_dangerous
                  - hard_blocked
                  - unverifiable
                  description: The verdict's threat_class as returned by /api/scan (functions/lib/scan-base.js THREAT). Must
                    match the class bound into feedback_token.
                reason_class:
                  type: string
                  maxLength: 24
                  enum:
                  - false_positive
                  - false_negative
                  - wrong_type
                  - stale_data
                  - missing_feature
                  - other
                  description: Reason category explaining why the verdict appears incorrect.
                feedback_token:
                  type: string
                  maxLength: 512
                  description: Proof-of-scan token from the /api/scan response's `feedback_token` field. Single-use.
                turnstile_token:
                  type: string
                  maxLength: 2048
                  description: Cloudflare Turnstile response token (execute mode). Required only when TURNSTILE_SECRET is
                    configured server-side; otherwise ignored.
                comment:
                  type: string
                  maxLength: 600
                  description: Optional free-text elaboration from the user.
                email:
                  type: string
                  maxLength: 200
                  format: email
                  description: Optional contact email for follow-up.
      responses:
        '200':
          description: Feedback accepted and stored
          content:
            application/json:
              schema:
                type: object
                required:
                - ok
                - report_id
                properties:
                  ok:
                    type: boolean
                    enum:
                    - true
                  report_id:
                    type: string
                    description: Unique identifier for this report, e.g. fb_<timestamp>_<hash-prefix>.
                additionalProperties: false
        '400':
          description: Request body is malformed or fails validation
          content:
            application/json:
              schema:
                allOf:
                - $ref: '#/components/schemas/ErrorEnvelope'
                - properties:
                    error:
                      properties:
                        code:
                          type: string
                          enum:
                          - invalid_json
                          - validation
                          - invalid_payload_hash
                          - feedback_token_missing
                          - feedback_token_malformed
                          - feedback_token_invalid
                          - feedback_token_expired
                          - turnstile_token_missing
                        errors:
                          type: array
                          description: Present when error is "validation"; lists field-level failures.
                          items:
                            type: string
        '403':
          description: feedback_token does not match this request or has already been used, or turnstile_token failed Cloudflare
            verification
          content:
            application/json:
              schema:
                allOf:
                - $ref: '#/components/schemas/ErrorEnvelope'
                - properties:
                    error:
                      properties:
                        code:
                          type: string
                          enum:
                          - feedback_token_mismatch
                          - feedback_token_used
                          - turnstile_verification_failed
        '429':
          description: Per-IP rate limit exceeded (10 per hour)
          headers:
            Retry-After:
              schema:
                type: integer
              description: Seconds until the rate limit window resets.
          content:
            application/json:
              schema:
                allOf:
                - $ref: '#/components/schemas/ErrorEnvelope'
                - properties:
                    error:
                      properties:
                        code:
                          type: string
                          enum:
                          - rate_limited
                        retry_after:
                          type: integer
                          description: Seconds until the rate limit window resets.
        '500':
          description: KV write failed
          content:
            application/json:
              schema:
                allOf:
                - $ref: '#/components/schemas/ErrorEnvelope'
                - properties:
                    error:
                      properties:
                        code:
                          type: string
                          enum:
                          - storage_error
        '503':
          description: KV binding or MUTATION_ALERT_KEY not configured (fails closed; feedback_token cannot be verified without
            the key)
          content:
            application/json:
              schema:
                allOf:
                - $ref: '#/components/schemas/ErrorEnvelope'
                - properties:
                    error:
                      properties:
                        code:
                          type: string
                          enum:
                          - not_configured
      operationId: postV1Feedback
  /api/v1/founding-members:
    get:
      tags:
      - founding-members
      summary: Check founding member offer availability for Check QR
      description: 'Returns whether the Check QR founding member offer is still open, how many days remain until the deadline,
        and which tiers are available.


        The client uses this response to render a live countdown and disable purchase buttons after the deadline passes. The
        deadline for this product is 2027-01-01T08:00:00Z.


        No authentication required. Public endpoint. Responses are cached for one hour (Cache-Control: public, max-age=3600).
        No side effects.

        '
      responses:
        '200':
          description: Offer status returned
          content:
            application/json:
              schema:
                type: object
                required:
                - available
                - deadline
                - days_remaining
                - product
                - tiers
                properties:
                  available:
                    type: boolean
                    description: True if the current time is before the deadline.
                  deadline:
                    type: string
                    format: date-time
                    description: ISO 8601 timestamp of when the founding member offer closes.
                  days_remaining:
                    type: integer
                    minimum: 0
                    description: Whole days left until the deadline, floored at 0 after it passes.
                  product:
                    type: string
                    description: Product identifier for this surface.
                    enum:
                    - check
                  tiers:
                    type: array
                    items:
                      type: string
                      enum:
                      - personal
                      - family
                      - starter
                      - pro
                    description: Founding member tiers available for this product.
                additionalProperties: false
        '404':
          description: Error
          content:
            application/json:
              schema:
                allOf:
                - $ref: '#/components/schemas/ErrorEnvelope'
                - properties:
                    error:
                      properties:
                        retry_after:
                          type: integer
                          description: Seconds to wait before retry. Present on 429.
      operationId: getV1FoundingMembers
    options:
      tags:
      - founding-members
      summary: CORS preflight for founding member endpoint
      description: 'Handles CORS preflight requests for GET /api/v1/founding-members. Returns the allowed methods and headers.
        No authentication required.

        '
      responses:
        '204':
          description: Preflight accepted
      operationId: optionsV1FoundingMembers
  /api/v1/mutation-alerts/confirm:
    post:
      tags:
      - mutation-alerts
      summary: Confirm a mutation-alert subscription via double-opt-in
      description: 'Completes the double-opt-in flow for a mutation-alert subscription. The caller

        supplies the subscription_id and the confirm_token from the emailed confirmation

        link. The token''s keyed hash is compared in constant time against the pending

        KV record.


        On first confirmation the endpoint flips the subscription state from pending to

        active, mints a 48-hex-char unsubscribe token, removes the confirm token hash from

        storage, and extends the KV TTL to 365 days so the record survives until the

        digest cron job picks it up.


        If the subscription is already active the call is idempotent: the existing

        unsubscribe token is returned along with `already: true`.


        No authentication is required. The confirm_token (minimum 48 chars) serves as

        the bearer credential. Rate-limited to 20 attempts per IP per hour and 10 per

        subscription per hour; a limiter outage refuses the request rather than

        letting it through. No PII is included in any response body.

        '
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - subscription_id
              - confirm_token
              properties:
                subscription_id:
                  type: string
                  maxLength: 80
                  description: Opaque identifier for the pending subscription record.
                confirm_token:
                  type: string
                  minLength: 48
                  maxLength: 80
                  description: Secret token from the emailed confirmation link; used to prove ownership of the address.
      responses:
        '200':
          description: Subscription confirmed (or already active)
          content:
            application/json:
              schema:
                type: object
                required:
                - ok
                - state
                - unsubscribe_token
                - subscription_id
                properties:
                  ok:
                    type: boolean
                    enum:
                    - true
                  state:
                    type: string
                    enum:
                    - active
                  already:
                    type: boolean
                    description: Present and true only when the subscription was already active before this call.
                  unsubscribe_token:
                    type: string
                    description: Token the subscriber can use to cancel the subscription.
                  subscription_id:
                    type: string
                    description: Echo of the subscription_id from the stored record.
                additionalProperties: false
        '400':
          description: Invalid JSON body or schema validation failure
          content:
            application/json:
              schema:
                allOf:
                - $ref: '#/components/schemas/ErrorEnvelope'
                - properties:
                    error:
                      properties:
                        code:
                          type: string
                          enum:
                          - invalid_json
                          - validation
                        errors:
                          type: array
                          description: Present when error is "validation"; lists field-level messages.
                          items:
                            type: string
        '403':
          description: confirm_token does not match the stored record, or the record predates emailed confirmation
          content:
            application/json:
              schema:
                allOf:
                - $ref: '#/components/schemas/ErrorEnvelope'
                - properties:
                    error:
                      properties:
                        code:
                          type: string
                          enum:
                          - bad_token
        '404':
          description: No pending subscription found for the given subscription_id, or it has expired
          content:
            application/json:
              schema:
                allOf:
                - $ref: '#/components/schemas/ErrorEnvelope'
                - properties:
                    error:
                      properties:
                        code:
                          type: string
                          enum:
                          - not_found_or_expired
        '429':
          description: Rate limit exceeded
          content:
            application/json:
              schema:
                allOf:
                - $ref: '#/components/schemas/ErrorEnvelope'
                - properties:
                    error:
                      properties:
                        code:
                          type: string
                          enum:
                          - rate_limited
                        retry_after:
                          type: integer
                          description: Seconds until the rate limit window resets.
        '500':
          description: Internal error, corrupt KV record or storage write failure
          content:
            application/json:
              schema:
                allOf:
                - $ref: '#/components/schemas/ErrorEnvelope'
                - properties:
                    error:
                      properties:
                        code:
                          type: string
                          enum:
                          - corrupt_record
                          - storage_error
        '503':
          description: KV namespace or MUTATION_ALERT_KEY not configured, or the rate limiter is unavailable
          content:
            application/json:
              schema:
                allOf:
                - $ref: '#/components/schemas/ErrorEnvelope'
                - properties:
                    error:
                      properties:
                        code:
                          type: string
                          enum:
                          - not_configured
                          - rate_limit_unavailable
      operationId: postV1MutationAlertsConfirm
  /api/v1/mutation-alerts/subscribe:
    post:
      tags:
      - mutation-alerts
      summary: Subscribe to QR payload mutation alerts
      description: 'Captures intent to receive mutation watchlist alerts for a given QR payload hash (QR-17). Stores a pending
        subscription record in KV. The full scanned payload is never stored; the record holds a keyed hash of the payload
        digest, a keyed hash of the address for lookup, and the address encrypted under a server-held key.


        Double opt-in: the confirmation link is emailed to the subscriber. The confirm token is never included in the response.
        The subscription remains in `state: "pending"` until confirmed via `POST /api/v1/mutation-alerts/confirm`. Unconfirmed
        rows expire after 7 days. Confirmed subscriptions persist for 1 year.


        Requires a valid `feedback_token` (from the same POST /api/scan response as `payload_hash`) proving the caller scanned
        this payload; the token need not be unused, but it must verify and its bound hash must match `payload_hash`. Fixes
        the report where any 64-hex payload_hash was accepted with no proof anyone scanned anything.


        No authentication is required. Rate-limited to 10 subscription attempts per IP per hour and 3 per address per hour.
        Operator IPs (configured via environment) bypass the per-IP limit. Requires `RATE_LIMIT_KV` and `MUTATION_ALERT_KEY`;
        returns 503 `not_configured` if either is absent (fails closed; feedback_token cannot be verified without the key).
        Returns 503 `feature_unavailable` while no confirmation sender is configured.


        Requires `turnstile_token` (Cloudflare Turnstile, execute mode) ONLY when

        TURNSTILE_SECRET is configured in the environment; while unset the check is

        skipped entirely (no widget exists yet). See functions/lib/turnstile-gate.js.

        '
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - payload_hash
              - feedback_token
              - email
              properties:
                payload_hash:
                  type: string
                  minLength: 64
                  maxLength: 64
                  pattern: ^[0-9a-fA-F]{64}$
                  description: SHA-256 hex digest of the scanned QR payload. Must match the hash bound into feedback_token.
                feedback_token:
                  type: string
                  maxLength: 512
                  description: Proof-of-scan token from the /api/scan response's `feedback_token` field for this payload.
                    Not single-use here.
                turnstile_token:
                  type: string
                  maxLength: 2048
                  description: Cloudflare Turnstile response token (execute mode). Required only when TURNSTILE_SECRET is
                    configured server-side; otherwise ignored.
                email:
                  type: string
                  maxLength: 200
                  format: email
                  description: Subscriber email address. Lowercased and trimmed, then stored encrypted.
                frequency:
                  type: string
                  enum:
                  - daily
                  - weekly
                  default: daily
                  description: Desired alert frequency. Defaults to daily if omitted.
                label:
                  type: string
                  maxLength: 80
                  description: Optional free-text note the user associates with this subscription.
      responses:
        '200':
          description: Subscription created in pending state; confirmation email sent
          content:
            application/json:
              schema:
                type: object
                required:
                - ok
                - subscription_id
                - state
                - next_step
                properties:
                  ok:
                    type: boolean
                    enum:
                    - true
                  subscription_id:
                    type: string
                    description: Stable identifier for this subscription, derived from keyed hashes of the payload digest
                      and the address.
                  state:
                    type: string
                    description: Subscription state; always "pending" until confirmed.
                    enum:
                    - pending
                  next_step:
                    type: string
                    description: Human-readable instruction telling the subscriber to open the emailed confirmation link.
                additionalProperties: false
        '400':
          description: Validation or input error
          content:
            application/json:
              schema:
                allOf:
                - $ref: '#/components/schemas/ErrorEnvelope'
                - properties:
                    error:
                      properties:
                        code:
                          type: string
                          enum:
                          - invalid_json
                          - validation
                          - invalid_payload_hash
                          - feedback_token_missing
                          - feedback_token_malformed
                          - feedback_token_invalid
                          - feedback_token_expired
                          - turnstile_token_missing
                        errors:
                          type: array
                          description: Field-level validation errors, present when error is "validation".
                          items:
                            type: string
        '403':
          description: feedback_token does not match this payload_hash, or turnstile_token failed Cloudflare verification
          content:
            application/json:
              schema:
                allOf:
                - $ref: '#/components/schemas/ErrorEnvelope'
                - properties:
                    error:
                      properties:
                        code:
                          type: string
                          enum:
                          - feedback_token_mismatch
                          - turnstile_verification_failed
        '429':
          description: Rate limit exceeded
          content:
            application/json:
              schema:
                allOf:
                - $ref: '#/components/schemas/ErrorEnvelope'
                - properties:
                    error:
                      properties:
                        code:
                          type: string
                          enum:
                          - rate_limited
                        retry_after:
                          type: integer
                          description: Seconds until the rate limit window resets.
        '500':
          description: KV storage write failed
          content:
            application/json:
              schema:
                allOf:
                - $ref: '#/components/schemas/ErrorEnvelope'
                - properties:
                    error:
                      properties:
                        code:
                          type: string
                          enum:
                          - storage_error
        '502':
          description: The confirmation email could not be sent; no subscription was kept
          content:
            application/json:
              schema:
                allOf:
                - $ref: '#/components/schemas/ErrorEnvelope'
                - properties:
                    error:
                      properties:
                        code:
                          type: string
                          enum:
                          - delivery_failed
        '503':
          description: Required binding or secret missing, or the confirmation sender is not configured yet
          content:
            application/json:
              schema:
                allOf:
                - $ref: '#/components/schemas/ErrorEnvelope'
                - properties:
                    error:
                      properties:
                        code:
                          type: string
                          enum:
                          - not_configured
                          - feature_unavailable
      operationId: postV1MutationAlertsSubscribe
  /api/v1/mutation-alerts/unsubscribe:
    post:
      tags:
      - mutation-alerts
      summary: Unsubscribe from mutation alerts
      description: 'Removes an active mutation-alert subscription identified by `subscription_id`.

        The caller must supply the `unsubscribe_token` that was minted at confirmation

        time; the token is compared in constant time to prevent timing attacks.


        No authentication header is required, the unsubscribe token itself is the

        credential. Tokens rotate with each email digest delivery so a leaked token

        from an old digest cannot be used against a still-active subscriber.


        The operation is idempotent: if the subscription has already been deleted,

        the endpoint returns HTTP 200 with `already: true` rather than an error,

        so a double-click or bookmark refresh on the unsubscribe page does not

        surface an error to the user.


        Rate-limited to 20 attempts per IP per hour. The endpoint is

        unauthenticated and open-CORS.

        '
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - subscription_id
              - unsubscribe_token
              properties:
                subscription_id:
                  type: string
                  maxLength: 80
                  description: Opaque identifier for the subscription record.
                unsubscribe_token:
                  type: string
                  minLength: 48
                  maxLength: 80
                  description: Single-use token issued at confirmation time, rotated per digest delivery.
      responses:
        '200':
          description: Subscription deleted (or was already absent)
          content:
            application/json:
              schema:
                type: object
                required:
                - ok
                - state
                properties:
                  ok:
                    type: boolean
                    enum:
                    - true
                  state:
                    type: string
                    enum:
                    - deleted
                  already:
                    type: boolean
                    description: Present and true when the subscription was already gone before this call.
                additionalProperties: false
        '400':
          description: Invalid JSON body or schema validation failure
          content:
            application/json:
              schema:
                allOf:
                - $ref: '#/components/schemas/ErrorEnvelope'
                - properties:
                    error:
                      properties:
                        code:
                          type: string
                          enum:
                          - invalid_json
                          - validation
                        errors:
                          type: array
                          items:
                            type: string
                          description: Field-level validation messages; present only when error is "validation".
        '403':
          description: Unsubscribe token does not match the stored record
          content:
            application/json:
              schema:
                allOf:
                - $ref: '#/components/schemas/ErrorEnvelope'
                - properties:
                    error:
                      properties:
                        code:
                          type: string
                          enum:
                          - bad_token
        '429':
          description: Rate limit exceeded
          content:
            application/json:
              schema:
                allOf:
                - $ref: '#/components/schemas/ErrorEnvelope'
                - properties:
                    error:
                      properties:
                        code:
                          type: string
                          enum:
                          - rate_limited
                        retry_after:
                          type: integer
                          description: Seconds until the rate limit window resets.
        '500':
          description: Corrupt KV record or storage deletion failure
          content:
            application/json:
              schema:
                allOf:
                - $ref: '#/components/schemas/ErrorEnvelope'
                - properties:
                    error:
                      properties:
                        code:
                          type: string
                          enum:
                          - corrupt_record
                          - storage_error
        '503':
          description: KV binding not configured
          content:
            application/json:
              schema:
                allOf:
                - $ref: '#/components/schemas/ErrorEnvelope'
                - properties:
                    error:
                      properties:
                        code:
                          type: string
                          enum:
                          - not_configured
      operationId: postV1MutationAlertsUnsubscribe
  /api/v1/payments/founding-checkout:
    post:
      tags:
      - payments
      summary: Create a Stripe Checkout Session for a Founding Member subscription
      description: 'Creates a Stripe Checkout Session for a Check Founding Member subscription at one of four annual pricing
        tiers (personal, family, starter, pro). No authentication is required, this is a public endpoint.


        The founding offer has a hard deadline of 2027-01-01T08:00:00Z. Requests after that date receive 410 Gone.


        Tier-to-price mapping is resolved server-side via environment variables (`STRIPE_FOUNDING_PRICE_*`). The caller supplies
        only the tier name and an optional pre-fill email address. On success the response contains a Stripe-hosted Checkout
        URL the client should redirect to.


        3DS/SCA is forced for all card payments (`request_three_d_secure: any`) to shift fraud-dispute liability. Stripe Radar
        handles further risk scoring.


        Rate limit: 5 founding-checkout attempts per IP per hour via `RATE_LIMIT_KV`. Operator IPs are exempt. Rate-limit
        failures are fail-open (a KV error does not block the request).


        Session metadata records `type=founding_member`, `plan=<tier>`, and `product=qrcheck` at both the Session and Subscription
        levels. Promotion codes are disabled; billing address collection is automatic.

        '
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - tier
              properties:
                tier:
                  type: string
                  enum:
                  - personal
                  - family
                  - starter
                  - pro
                  description: Founding member pricing tier. Determines which Stripe price ID is used.
                email:
                  type: string
                  format: email
                  description: Optional customer email to pre-fill in Stripe Checkout.
      responses:
        '200':
          description: Stripe Checkout Session created successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  url:
                    type: string
                    format: uri
                    description: Stripe-hosted Checkout URL. Redirect the user here to complete payment.
                  id:
                    type: string
                    description: Stripe Checkout Session ID.
                additionalProperties: false
        '400':
          description: Invalid request body or unsupported tier
          content:
            application/json:
              schema:
                allOf:
                - $ref: '#/components/schemas/ErrorEnvelope'
                - properties:
                    error:
                      properties:
                        code:
                          type: string
                          enum:
                          - invalid_json
                          - invalid_tier
                          - stripe_error
                        message:
                          type: string
                        detail:
                          type: string
                          description: Stripe error message, present only when error is stripe_error.
                        stripe_status:
                          type: integer
                          description: HTTP status returned by Stripe, present only when error is stripe_error.
        '410':
          description: Founding Member offer has closed
          content:
            application/json:
              schema:
                allOf:
                - $ref: '#/components/schemas/ErrorEnvelope'
                - properties:
                    error:
                      properties:
                        code:
                          type: string
                          enum:
                          - founding_offer_closed
                        message:
                          type: string
        '429':
          description: Per-IP rate limit exceeded
          headers:
            Retry-After:
              schema:
                type: integer
              description: Seconds until the rate limit window resets.
          content:
            application/json:
              schema:
                allOf:
                - $ref: '#/components/schemas/ErrorEnvelope'
                - properties:
                    error:
                      properties:
                        code:
                          type: string
                          enum:
                          - rate_limited
                        retry_after:
                          type: integer
                          description: Seconds until the next attempt is allowed.
                        message:
                          type: string
        '500':
          description: Network error reaching Stripe or unparseable Stripe response
          content:
            application/json:
              schema:
                allOf:
                - $ref: '#/components/schemas/ErrorEnvelope'
                - properties:
                    error:
                      properties:
                        code:
                          type: string
                          enum:
                          - network_error
                          - stripe_error
                        detail:
                          type: string
                          description: Error detail string, present for network_error.
        '502':
          description: Stripe returned a 5xx error
          content:
            application/json:
              schema:
                allOf:
                - $ref: '#/components/schemas/ErrorEnvelope'
                - properties:
                    error:
                      properties:
                        code:
                          type: string
                          enum:
                          - stripe_error
                        detail:
                          type: string
                        stripe_status:
                          type: integer
        '503':
          description: Stripe not configured or tier price ID missing
          content:
            application/json:
              schema:
                allOf:
                - $ref: '#/components/schemas/ErrorEnvelope'
                - properties:
                    error:
                      properties:
                        code:
                          type: string
                          enum:
                          - not_configured
                          - tier_not_configured
                        message:
                          type: string
      operationId: postV1PaymentsFoundingCheckout
  /api/whereami:
    get:
      tags:
      - whereami
      summary: Return visitor's country code from Cloudflare geo header
      description: 'Returns the visitor''s ISO 3166-1 alpha-2 country code as detected by Cloudflare''s

        edge network via the `cf-ipcountry` request header.


        Intended for picking a demo redirect destination guaranteed to be in a different

        country than the visitor. No authentication required.


        No PII is captured or logged. The only Cloudflare geo field read is the two-letter

        country code. IP address, ASN, city, latitude, longitude, and all other enrichment

        fields are explicitly ignored.


        Cloudflare may set the header to `"XX"` for unknown origins or `"T1"` for Tor exit

        nodes; both values are passed through unchanged. When running outside a Cloudflare

        context (local dev, missing CF edge), the header is absent and `country` is `null`.


        No rate limits apply. No side effects. Responses are not cached (`cache-control: no-store`).

        '
      responses:
        '200':
          description: Country code resolved (or null if CF context unavailable)
          content:
            application/json:
              schema:
                type: object
                required:
                - country
                properties:
                  country:
                    nullable: true
                    oneOf:
                    - type: string
                      minLength: 2
                      maxLength: 2
                      description: Null when the cf-ipcountry header is absent (local dev or missing Cloudflare edge context).
                additionalProperties: false
        '404':
          description: Error
          content:
            application/json:
              schema:
                allOf:
                - $ref: '#/components/schemas/ErrorEnvelope'
                - properties:
                    error:
                      properties:
                        retry_after:
                          type: integer
                          description: Seconds to wait before retry. Present on 429.
      operationId: getWhereami
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: JWT bearer token issued by the Abundera hub.
  schemas:
    ErrorEnvelope:
      type: object
      required:
      - ok
      - error
      - request_id
      description: Canonical error envelope shared by every Abundera product (docs/API-CONSISTENCY-CONTRACT.md). Emitted by
        functions/lib/response.js:fail().
      properties:
        ok:
          type: boolean
          enum:
          - false
          description: False on error.
        error:
          type: object
          required:
          - code
          - message
          additionalProperties: true
          description: Additional machine-readable fields (validation field names, plan/tier hints, retry hints, etc.) merge
            in here per error code.
          properties:
            code:
              type: string
              description: Machine-readable error code.
            message:
              type: string
              description: Human-readable detail.
            remediation:
              type: string
              description: How to fix the request, in one sentence.
            doc_url:
              type: string
              description: Deep link into the central errors reference.
        request_id:
          type: string
          nullable: true
          description: Request correlation id, echoed as the X-Request-ID header.
tags:
- name: .well-known
- name: auth
- name: billing
- name: coverage
- name: feedback
- name: founding-members
- name: mutation-alerts
- name: payments
- name: scan
- name: whereami
