{
  "info": {
    "name": "Abundera QR Check API",
    "description": "QR / barcode safety classification service.",
    "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
  },
  "auth": {
    "type": "bearer",
    "bearer": [
      {
        "key": "token",
        "value": "{{jwt_token}}",
        "type": "string"
      }
    ]
  },
  "variable": [
    {
      "key": "base_url",
      "value": "https://check.qr.abundera.ai"
    },
    {
      "key": "jwt_token",
      "value": "",
      "description": "JWT Bearer token from Abundera auth"
    }
  ],
  "item": [
    {
      "name": ".well-known",
      "description": "",
      "item": [
        {
          "name": "Fetch Abundera Check product capabilities document",
          "request": {
            "method": "GET",
            "url": {
              "raw": "{{base_url}}/.well-known/abundera-capabilities.json",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                ".well-known",
                "abundera-capabilities.json"
              ]
            },
            "description": "JSON document describing the check.qr.abundera.ai API surface, OAuth-style\nscopes, and rate-limit tiers. Follows RFC 8615 (well-known URI) and is\naggregated by abundera.ai's federated /account/api-keys UI.\n\nConsumers must ignore unknown fields (RFC 8259 §4). Minor schema_version\nbumps are backward-compatible.\n\nPublic; no authentication required. CORS-open so cross-origin aggregators\nand integrators can fetch directly.\n\nCaching: `Cache-Control: public, max-age=60, stale-while-revalidate=600`.\nA weak ETag (first 32 hex of body SHA-256) supports `If-None-Match` 304s.\n"
          }
        },
        {
          "name": "known/gpc.json",
          "request": {
            "method": "GET",
            "url": {
              "raw": "{{base_url}}/.well-known/gpc.json",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                ".well-known",
                "gpc.json"
              ]
            },
            "description": "Global Privacy Control signal acknowledgement. Declares that this\nsite honors the GPC header (Sec-GPC: 1) as a valid opt-out of sale\nand sharing of personal information under CCPA/CPRA. Format per\nhttps://privacycg.github.io/gpc-spec/."
          }
        },
        {
          "name": "known/llms.txt",
          "request": {
            "method": "GET",
            "url": {
              "raw": "{{base_url}}/.well-known/llms.txt",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                ".well-known",
                "llms.txt"
              ]
            },
            "description": "Permanent alias to /llms.txt. Some LLM crawlers probe the well-known\nlocation before the root; this 301 keeps a single canonical file.\nSee ~/projects/siteops/docs/LLMS-TXT-STANDARD.md."
          }
        },
        {
          "name": "policy.json",
          "request": {
            "method": "GET",
            "url": {
              "raw": "{{base_url}}/.well-known/qr-safety-policy.json",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                ".well-known",
                "qr-safety-policy.json"
              ]
            },
            "description": "Family-canonical declaration of how this Abundera Check instance\nclassifies QR / barcode payload safety. Lets external integrators\n(browsers, MDMs, mail filters, sister-product safety pipelines)\nconsume verdicts knowing exactly what the verdict schema means.\n\nPair with /.well-known/qr-redirect-policy.json on qr.abundera.ai ,\none declares \"how I mint redirects\", the other declares \"how I\nclassify them.\"\n\nSchema: ~/projects/siteops/docs/WELL-KNOWN-CATALOG.md"
          }
        }
      ]
    },
    {
      "name": "auth",
      "description": "",
      "item": [
        {
          "name": "RBAC introspection for the current credential",
          "request": {
            "method": "GET",
            "url": {
              "raw": "{{base_url}}/api/v1/auth/whoami",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "auth",
                "whoami"
              ]
            },
            "description": "Side-effect-free. Returns `{ authenticated, product, user_id, email,\nname, auth_source, role, tier, scopes, key, rate_limit }` for the\npresented JWT/API key, or `{ authenticated:false, reason }` (200)\nwhen absent/invalid. Never cached.\n"
          }
        }
      ]
    },
    {
      "name": "billing",
      "description": "",
      "item": [
        {
          "name": "Read the sanitized result of a founding-member checkout",
          "request": {
            "method": "GET",
            "url": {
              "raw": "{{base_url}}/api/billing/session",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "billing",
                "session"
              ],
              "query": [
                {
                  "key": "session_id",
                  "value": "",
                  "description": "Stripe Checkout Session id from the success URL."
                }
              ]
            },
            "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.\n"
          }
        }
      ]
    },
    {
      "name": "coverage",
      "description": "",
      "item": [
        {
          "name": "Public QR Type Catalog manifest",
          "request": {
            "method": "GET",
            "url": {
              "raw": "{{base_url}}/api/coverage",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "coverage"
              ]
            },
            "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.\n"
          }
        }
      ]
    },
    {
      "name": "feedback",
      "description": "",
      "item": [
        {
          "name": "Submit feedback on a scan verdict",
          "request": {
            "method": "POST",
            "url": {
              "raw": "{{base_url}}/api/v1/feedback",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "feedback"
              ]
            },
            "description": "Collects user feedback on a verdict produced by the scan API. No authentication\nis required. The scanned payload is never stored, only a SHA-256 hash is kept,\nmatching the privacy posture of the verdict cache (QR-17/18 §6.4.x).\n\nRequires `feedback_token`, the proof-of-scan token POST /api/scan minted on the\nverdict this report is about (functions/lib/feedback-token.js). The token must\nverify, be unexpired (24h), match this request's `payload_hash` and\n`verdict_class`, and not have been spent before (single-use). Without it (or with\na bad one) the report is rejected: this closes the gap where any 64-hex\npayload_hash plus free-text verdict_class was accepted with no proof the caller\never scanned anything.\n\nEach report is stored in the RATE_LIMIT_KV namespace under a key of the form\n`feedback:<ts>:<hash-prefix>` with a 90-day TTL. No D1 dependency.\n\nFeedback is advisory only (docs/FEEDBACK-POLICY.md): it never changes a verdict,\na cache entry, or a warning automatically. Each accepted report also updates a\nper-payload aggregate (functions/lib/feedback-aggregate.js, `fbagg:<payload_hash>`)\ncounting one vote per source per day, a source being a daily-rotating HMAC of the\ncaller's IP network prefix and ASN. A repeat report from the same source on the\nsame payload the same day still answers 200 with a `report_id` (the dedupe is\nnever revealed) but writes no second `feedback:` record. Operators read the\naggregate via `GET /api/v1/ops/feedback?view=aggregate`.\n\nRate-limited to 10 requests per hour per IP address. Operator IPs (as configured\nin the environment) bypass the rate limit. If the KV binding or MUTATION_ALERT_KEY\nis unavailable the endpoint returns 503 (fails closed; the token cannot be\nverified without the key). Rate-limit failures are non-fatal and fail open so\nlegitimate feedback is not blocked.\n\nRequires `turnstile_token` (Cloudflare Turnstile, execute mode) ONLY when\nTURNSTILE_SECRET is configured in the environment; while unset the check is\nskipped entirely (no widget exists yet). See functions/lib/turnstile-gate.js.\n",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"payload_hash\": \"{{signing_token}}\",\n  \"verdict_class\": \"likely_safe\",\n  \"reason_class\": \"false_positive\",\n  \"feedback_token\": \"{{signing_token}}\",\n  \"turnstile_token\": \"{{signing_token}}\",\n  \"comment\": \"string\",\n  \"email\": \"user@example.com\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            },
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ]
          }
        }
      ]
    },
    {
      "name": "founding-members",
      "description": "",
      "item": [
        {
          "name": "Check founding member offer availability for Check QR",
          "request": {
            "method": "GET",
            "url": {
              "raw": "{{base_url}}/api/v1/founding-members",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "founding-members"
              ]
            },
            "description": "Returns whether the Check QR founding member offer is still open, how many days remain until the deadline, and which tiers are available.\n\nThe 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.\n\nNo authentication required. Public endpoint. Responses are cached for one hour (Cache-Control: public, max-age=3600). No side effects.\n"
          }
        }
      ]
    },
    {
      "name": "mutation-alerts",
      "description": "",
      "item": [
        {
          "name": "Confirm a mutation-alert subscription via double-opt-in",
          "request": {
            "method": "POST",
            "url": {
              "raw": "{{base_url}}/api/v1/mutation-alerts/confirm",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "mutation-alerts",
                "confirm"
              ]
            },
            "description": "Completes the double-opt-in flow for a mutation-alert subscription. The caller\nsupplies the subscription_id and the confirm_token from the emailed confirmation\nlink. The token's keyed hash is compared in constant time against the pending\nKV record.\n\nOn first confirmation the endpoint flips the subscription state from pending to\nactive, mints a 48-hex-char unsubscribe token, removes the confirm token hash from\nstorage, and extends the KV TTL to 365 days so the record survives until the\ndigest cron job picks it up.\n\nIf the subscription is already active the call is idempotent: the existing\nunsubscribe token is returned along with `already: true`.\n\nNo authentication is required. The confirm_token (minimum 48 chars) serves as\nthe bearer credential. Rate-limited to 20 attempts per IP per hour and 10 per\nsubscription per hour; a limiter outage refuses the request rather than\nletting it through. No PII is included in any response body.\n",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"subscription_id\": \"string\",\n  \"confirm_token\": \"{{signing_token}}\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            },
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ]
          }
        },
        {
          "name": "Subscribe to QR payload mutation alerts",
          "request": {
            "method": "POST",
            "url": {
              "raw": "{{base_url}}/api/v1/mutation-alerts/subscribe",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "mutation-alerts",
                "subscribe"
              ]
            },
            "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.\n\nDouble 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.\n\nRequires 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.\n\nNo 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.\n\nRequires `turnstile_token` (Cloudflare Turnstile, execute mode) ONLY when\nTURNSTILE_SECRET is configured in the environment; while unset the check is\nskipped entirely (no widget exists yet). See functions/lib/turnstile-gate.js.\n",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"payload_hash\": \"{{signing_token}}\",\n  \"feedback_token\": \"{{signing_token}}\",\n  \"turnstile_token\": \"{{signing_token}}\",\n  \"email\": \"user@example.com\",\n  \"frequency\": \"daily\",\n  \"label\": \"string\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            },
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ]
          }
        },
        {
          "name": "Unsubscribe from mutation alerts",
          "request": {
            "method": "POST",
            "url": {
              "raw": "{{base_url}}/api/v1/mutation-alerts/unsubscribe",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "mutation-alerts",
                "unsubscribe"
              ]
            },
            "description": "Removes an active mutation-alert subscription identified by `subscription_id`.\nThe caller must supply the `unsubscribe_token` that was minted at confirmation\ntime; the token is compared in constant time to prevent timing attacks.\n\nNo authentication header is required, the unsubscribe token itself is the\ncredential. Tokens rotate with each email digest delivery so a leaked token\nfrom an old digest cannot be used against a still-active subscriber.\n\nThe operation is idempotent: if the subscription has already been deleted,\nthe endpoint returns HTTP 200 with `already: true` rather than an error,\nso a double-click or bookmark refresh on the unsubscribe page does not\nsurface an error to the user.\n\nRate-limited to 20 attempts per IP per hour. The endpoint is\nunauthenticated and open-CORS.\n",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"subscription_id\": \"string\",\n  \"unsubscribe_token\": \"{{signing_token}}\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            },
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ]
          }
        }
      ]
    },
    {
      "name": "payments",
      "description": "",
      "item": [
        {
          "name": "Create a Stripe Checkout Session for a Founding Member subscription",
          "request": {
            "method": "POST",
            "url": {
              "raw": "{{base_url}}/api/v1/payments/founding-checkout",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "payments",
                "founding-checkout"
              ]
            },
            "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.\n\nThe founding offer has a hard deadline of 2027-01-01T08:00:00Z. Requests after that date receive 410 Gone.\n\nTier-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.\n\n3DS/SCA is forced for all card payments (`request_three_d_secure: any`) to shift fraud-dispute liability. Stripe Radar handles further risk scoring.\n\nRate 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).\n\nSession 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.\n",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"tier\": \"personal\",\n  \"email\": \"user@example.com\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            },
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ]
          }
        }
      ]
    },
    {
      "name": "scan",
      "description": "",
      "item": [
        {
          "name": "Classify a decoded QR / barcode payload",
          "request": {
            "method": "POST",
            "url": {
              "raw": "{{base_url}}/api/scan",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "scan"
              ]
            },
            "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).\n",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"payload\": \"string\",\n  \"client_decoded_type\": \"string\",\n  \"decoder_format\": \"qr_code\",\n  \"decoder_source\": \"camera\",\n  \"embedded_logo_hint\": {\n    \"brand_label\": \"string\",\n    \"confidence\": 0,\n    \"model_version\": \"string\"\n  }\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            },
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ]
          }
        }
      ]
    },
    {
      "name": "whereami",
      "description": "",
      "item": [
        {
          "name": "Return visitor's country code from Cloudflare geo header",
          "request": {
            "method": "GET",
            "url": {
              "raw": "{{base_url}}/api/whereami",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "whereami"
              ]
            },
            "description": "Returns the visitor's ISO 3166-1 alpha-2 country code as detected by Cloudflare's\nedge network via the `cf-ipcountry` request header.\n\nIntended for picking a demo redirect destination guaranteed to be in a different\ncountry than the visitor. No authentication required.\n\nNo PII is captured or logged. The only Cloudflare geo field read is the two-letter\ncountry code. IP address, ASN, city, latitude, longitude, and all other enrichment\nfields are explicitly ignored.\n\nCloudflare may set the header to `\"XX\"` for unknown origins or `\"T1\"` for Tor exit\nnodes; both values are passed through unchanged. When running outside a Cloudflare\ncontext (local dev, missing CF edge), the header is absent and `country` is `null`.\n\nNo rate limits apply. No side effects. Responses are not cached (`cache-control: no-store`).\n"
          }
        }
      ]
    }
  ]
}