{
  "info": {
    "name": "Abundera QR Pro API",
    "description": "REST API for Abundera QR Pro, create, edit, and analyze dynamic QR codes. Bearer-token auth, explicit rate limits, privacy-first analytics.",
    "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
    "_postman_id": "abundera-generated",
    "version": "1.0.0"
  },
  "auth": {
    "type": "bearer",
    "bearer": [
      {
        "key": "token",
        "value": "{{token}}",
        "type": "string"
      }
    ]
  },
  "variable": [
    {
      "key": "base_url",
      "value": "https://pro.qr.abundera.ai",
      "type": "string"
    },
    {
      "key": "token",
      "value": "",
      "type": "string"
    }
  ],
  "item": [
    {
      "name": ".well-known",
      "item": [
        {
          "name": "Fetch Abundera product capabilities document",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "If-None-Match",
                "value": "string",
                "description": "Weak ETag from a prior response. If it matches the current document hash, the server returns 304 Not Modified with no body.\n",
                "disabled": true
              }
            ],
            "url": {
              "raw": "{{base_url}}/.well-known/abundera-capabilities.json",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                ".well-known",
                "abundera-capabilities.json"
              ],
              "query": []
            },
            "description": "Returns a JSON document describing this product's API surface, OAuth-style scopes,\nwebhook event types, notification topics, and rate-limit tiers. Follows the RFC 8615\nwell-known URI pattern (analogous to OIDC Discovery and NodeInfo).\n\nThe document is consumed by the abundera.ai hub's federated /account/api-keys/ and\n/account/webhooks/ UIs, which aggregate capabilities documents from every product in\nthe family. Consumers must ignore unknown fields (RFC 8259 \u00a74). Minor schema_version\nbumps are backward-compatible; major bumps require aggregator updates.\n\nNo authentication is required. The document is fully public and served with\n`Access-Control-Allow-Origin: *` so cross-origin aggregators can fetch it directly.\n\nCaching: `Cache-Control: public, max-age=60, stale-while-revalidate=600`. A weak ETag\nis derived from the SHA-256 of the serialized body (first 32 hex chars). Conditional\nGET with `If-None-Match` is supported; a matching ETag returns 304 with no body.\n",
            "auth": {
              "type": "noauth"
            }
          },
          "response": []
        },
        {
          "name": "CORS preflight for capabilities endpoint",
          "request": {
            "method": "OPTIONS",
            "header": [],
            "url": {
              "raw": "{{base_url}}/.well-known/abundera-capabilities.json",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                ".well-known",
                "abundera-capabilities.json"
              ],
              "query": []
            },
            "description": "Handles CORS preflight requests for the capabilities document endpoint.\nAllows GET and OPTIONS from any origin, and permits the If-None-Match request header.\nMax-Age is set to 86400 seconds (24 hours) to minimize preflight overhead for aggregators.\n",
            "auth": {
              "type": "noauth"
            }
          },
          "response": []
        },
        {
          "name": "known/gpc.json",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/.well-known/gpc.json",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                ".well-known",
                "gpc.json"
              ],
              "query": []
            },
            "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/.",
            "auth": {
              "type": "noauth"
            }
          },
          "response": []
        },
        {
          "name": "known/llms.txt",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/.well-known/llms.txt",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                ".well-known",
                "llms.txt"
              ],
              "query": []
            },
            "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.",
            "auth": {
              "type": "noauth"
            }
          },
          "response": []
        }
      ]
    },
    {
      "name": "admin",
      "item": [
        {
          "name": "sweep?dry_run=1",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/admin/archival-sweep",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "admin",
                "archival-sweep"
              ],
              "query": []
            },
            "description": "Patent QR-05 \u00a76.x: trigger-driven automatic archival sweep. Evaluates\nevery code in the preservation (Keep-Alive) state against the inactivity\ntrigger from the defined Markush group:\n  - inactivity criterion: fewer than ARCHIVAL_MIN_SCANS scans in the\n    rolling LOOKBACK_DAYS window\n\nOn dry_run, returns the candidate set without state transitions. On\nnon-dry runs (admin-only), advances the affected codes to status =\n'archived'.\n\nAuthor intent: prevents the preservation tier from being used as a\nfree general-purpose URL shortener while preserving low-traffic\nlegitimate prints (the LOOKBACK_DAYS window is multi-year by design)."
          },
          "response": []
        },
        {
          "name": "Fires the weekly or monthly digest push to every user with the",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v1/admin/digest",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "admin",
                "digest"
              ],
              "query": []
            },
            "description": "Fires the weekly or monthly digest push to every user with the\nmatching topic opt-in (weekly_digest / monthly_summary). Driven by\nqr-redirect-worker cron:\n  weekly_digest  , Monday 08:00 UTC\n  monthly_summary, 1st of month 08:00 UTC\n\nService-secret gated. The worker POSTs with { kind: 'weekly' |\n'monthly' }; this endpoint enumerates opted-in users, computes a\nshort per-user stat line (total scans in the window), and fires one\npush per user through the full gate stack (master / DND / topic).\n\nDeliberately thin on content, the push is a hook, not the entire\ndigest. Clicking takes the user to /stats/ which has the full\nbreakdown. Longer email digests can plug in here later without\nchanging the trigger surface.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"kind\": null\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        },
        {
          "name": "Service-secret gated. Called from an internal admin surface (or the",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/admin/enterprise",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "admin",
                "enterprise"
              ],
              "query": []
            },
            "description": "Service-secret gated. Called from an internal admin surface (or the\nsales-ops Linear workflow via a webhook automation) after the Stripe\ninvoice is issued + countersigned MSA is on file.\n\nBody (POST):\n  {\n    user_id: \"<uuid>\",\n    overrides: {\n      codes_limit?: int | null,\n      scan_limit_monthly?: int | null,\n      seat_limit?: int,\n      max_teams?: int,\n      analytics_days?: int,\n      audit_retention_days?: int,\n      ip_allowlist?: [\"10.0.0.0/8\", ...],\n      sso_required?: bool,\n      support_tier?: \"standard\" | \"priority\" | \"dedicated\",\n      data_region?: \"us\" | \"eu\" | \"fedramp\",\n      notes?: string\n    },\n    contract_end?: unix_seconds | null    (null = evergreen)\n  }\n\nvalue; explicit null clears)."
          },
          "response": []
        },
        {
          "name": "Service-secret gated. Called from an internal admin surface (or the",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v1/admin/enterprise",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "admin",
                "enterprise"
              ],
              "query": []
            },
            "description": "Service-secret gated. Called from an internal admin surface (or the\nsales-ops Linear workflow via a webhook automation) after the Stripe\ninvoice is issued + countersigned MSA is on file.\n\nBody (POST):\n  {\n    user_id: \"<uuid>\",\n    overrides: {\n      codes_limit?: int | null,\n      scan_limit_monthly?: int | null,\n      seat_limit?: int,\n      max_teams?: int,\n      analytics_days?: int,\n      audit_retention_days?: int,\n      ip_allowlist?: [\"10.0.0.0/8\", ...],\n      sso_required?: bool,\n      support_tier?: \"standard\" | \"priority\" | \"dedicated\",\n      data_region?: \"us\" | \"eu\" | \"fedramp\",\n      notes?: string\n    },\n    contract_end?: unix_seconds | null    (null = evergreen)\n  }\n\nvalue; explicit null clears).",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"contract_end\": null,\n  \"overrides\": null,\n  \"user_id\": null\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        },
        {
          "name": "Service-secret gated. Called from an internal admin surface (or the",
          "request": {
            "method": "PATCH",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v1/admin/enterprise",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "admin",
                "enterprise"
              ],
              "query": []
            },
            "description": "Service-secret gated. Called from an internal admin surface (or the\nsales-ops Linear workflow via a webhook automation) after the Stripe\ninvoice is issued + countersigned MSA is on file.\n\nBody (POST):\n  {\n    user_id: \"<uuid>\",\n    overrides: {\n      codes_limit?: int | null,\n      scan_limit_monthly?: int | null,\n      seat_limit?: int,\n      max_teams?: int,\n      analytics_days?: int,\n      audit_retention_days?: int,\n      ip_allowlist?: [\"10.0.0.0/8\", ...],\n      sso_required?: bool,\n      support_tier?: \"standard\" | \"priority\" | \"dedicated\",\n      data_region?: \"us\" | \"eu\" | \"fedramp\",\n      notes?: string\n    },\n    contract_end?: unix_seconds | null    (null = evergreen)\n  }\n\nvalue; explicit null clears).",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"contract_end\": null,\n  \"overrides\": null,\n  \"user_id\": null\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        },
        {
          "name": "Admin-only, returns the monthly rollup + recent events from",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/admin/gdpr-log",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "admin",
                "gdpr-log"
              ],
              "query": []
            },
            "description": "Admin-only, returns the monthly rollup + recent events from\ngdpr_requests. Used for the LEGAL_RISK monthly review (\"confirm we\nhonored the 30-day SLA on every deletion request\").\n\nAuth: X-Service-Secret header must match env.ABUNDERA_SERVICE_SECRET.\nSame pattern the abundera.ai service-to-service bridge uses; no\nseparate admin session yet. Intentional: this is run by internal\nreview cron / manual curl, not by end users."
          },
          "response": []
        },
        {
          "name": "Service-secret gated (ABUNDERA_SERVICE_SECRET)",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/admin/orgs",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "admin",
                "orgs"
              ],
              "query": []
            },
            "description": "Service-secret gated (ABUNDERA_SERVICE_SECRET).\n\nFederated: every operation forwards to abundera.ai's\n/auth/service/orgs/* endpoints. Pro QR holds no `organizations`\nD1 table; the only local state is `users.org_id` which we maintain\nhere as a denormalized cache of the user's primary org.\n\n  {\n    user_id, name, support_email?, brand_color?, accent_color?,\n    logo_url?, footer_text?, short_url_domain?, dashboard_domain?\n  }\n\ntrigger registerHostname / revokeHostname on the federation side."
          },
          "response": []
        },
        {
          "name": "Service-secret gated (ABUNDERA_SERVICE_SECRET)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v1/admin/orgs",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "admin",
                "orgs"
              ],
              "query": []
            },
            "description": "Service-secret gated (ABUNDERA_SERVICE_SECRET).\n\nFederated: every operation forwards to abundera.ai's\n/auth/service/orgs/* endpoints. Pro QR holds no `organizations`\nD1 table; the only local state is `users.org_id` which we maintain\nhere as a denormalized cache of the user's primary org.\n\n  {\n    user_id, name, support_email?, brand_color?, accent_color?,\n    logo_url?, footer_text?, short_url_domain?, dashboard_domain?\n  }\n\ntrigger registerHostname / revokeHostname on the federation side.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"accent_color\": null,\n  \"brand_color\": null,\n  \"dashboard_domain\": null,\n  \"logo_url\": null,\n  \"name\": null,\n  \"primary_color\": null,\n  \"short_url_domain\": null,\n  \"support_email\": null,\n  \"user_id\": null\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        },
        {
          "name": "Service-secret gated (ABUNDERA_SERVICE_SECRET)",
          "request": {
            "method": "PATCH",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v1/admin/orgs",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "admin",
                "orgs"
              ],
              "query": []
            },
            "description": "Service-secret gated (ABUNDERA_SERVICE_SECRET).\n\nFederated: every operation forwards to abundera.ai's\n/auth/service/orgs/* endpoints. Pro QR holds no `organizations`\nD1 table; the only local state is `users.org_id` which we maintain\nhere as a denormalized cache of the user's primary org.\n\n  {\n    user_id, name, support_email?, brand_color?, accent_color?,\n    logo_url?, footer_text?, short_url_domain?, dashboard_domain?\n  }\n\ntrigger registerHostname / revokeHostname on the federation side.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"accent_color\": null,\n  \"brand_color\": null,\n  \"dashboard_domain\": null,\n  \"logo_url\": null,\n  \"name\": null,\n  \"primary_color\": null,\n  \"short_url_domain\": null,\n  \"support_email\": null,\n  \"user_id\": null\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        },
        {
          "name": "Service-secret-gated trigger endpoint. Called by qr-redirect-worker",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v1/admin/push-fire",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "admin",
                "push-fire"
              ],
              "query": []
            },
            "description": "Service-secret-gated trigger endpoint. Called by qr-redirect-worker\nscheduled jobs (anomaly detection, code-expiring sweep, scan-cap\nabuse detector) to fire a push notification through the full pro.qr\npolicy gate stack (master toggle \u2192 federated DND \u2192 topic opt-in \u2192\ndelivery log).\n\nKeeps the worker stateless about policy: the worker sees \"anomaly\ndetected for code X owned by user U\" and POSTs here; pro.qr decides\nwhether to deliver, suppresses correctly, and writes the audit row.\n\nAuth: X-Service-Secret header must match env.ABUNDERA_SERVICE_SECRET.\n\nBody:\n  {\n    user_id:   \"<uuid>\",\n    topic?:    \"anomaly_alerts\" | \"code_expiring\" | \"quota_warning\"\n               | \"weekly_digest\" | \"monthly_summary\" | \"team_invite\"\n               | null,        // absent = transactional (bypass opt-in)\n    title:     \"Scan surge detected\",\n    body:      \"Code 'summer-2026' hit 3x its 14-day baseline...\",\n    url?:      \"/codes/edit/?id=abc123\"\n  }\n\nResponse: the send-result envelope from sendTopicPush /\n  sendTransactionalPush, { sent, failed, cleaned, suppressed? }.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"body\": null,\n  \"title\": null,\n  \"topic\": null,\n  \"url\": null,\n  \"user_id\": null\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        },
        {
          "name": "hosts        list all rows",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/admin/redirect-hosts",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "admin",
                "redirect-hosts"
              ],
              "query": []
            },
            "description": "X-Service-Secret gated (pattern from functions/api/admin/reservations/index.js).\n\n  {\n    host: \"enterprise.aqr.net\",                     // required\n    rule_kind: \"mirror\" | \"shortener\" | \"static_404\",\n    // mirror:\n    mirror_target: \"https://enterprise.qr.abundera.ai\",\n    mirror_status: 301 | 302,                       // default 301\n    // shortener:\n    prefix: \"\" | \"/r\",                              // default \"\"\n    product: \"qr\",\n    tenant_id: \"...\",\n    // meta:\n    status: \"active\" | \"paused\" | \"revoked\",        // default active\n    notes: \"...\"\n  }\n\nOn success: D1 INSERT, then KV PUT host:<host> with the rendered HostRule.\nIdempotent, duplicate host returns 409 (use PATCH to update)."
          },
          "response": []
        },
        {
          "name": "hosts        create a host",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v1/admin/redirect-hosts",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "admin",
                "redirect-hosts"
              ],
              "query": []
            },
            "description": "X-Service-Secret gated (pattern from functions/api/admin/reservations/index.js).\n\n  {\n    host: \"enterprise.aqr.net\",                     // required\n    rule_kind: \"mirror\" | \"shortener\" | \"static_404\",\n    // mirror:\n    mirror_target: \"https://enterprise.qr.abundera.ai\",\n    mirror_status: 301 | 302,                       // default 301\n    // shortener:\n    prefix: \"\" | \"/r\",                              // default \"\"\n    product: \"qr\",\n    tenant_id: \"...\",\n    // meta:\n    status: \"active\" | \"paused\" | \"revoked\",        // default active\n    notes: \"...\"\n  }\n\nOn success: D1 INSERT, then KV PUT host:<host> with the rendered HostRule.\nIdempotent, duplicate host returns 409 (use PATCH to update).",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"data\": null\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        },
        {
          "name": "host    read one",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/admin/redirect-hosts/:host",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "admin",
                "redirect-hosts",
                ":host"
              ],
              "query": []
            },
            "description": "Same X-Service-Secret pattern as the index handler.\n\n  { mirror_target, mirror_status, prefix, product, tenant_id,\n    status, branding_json, notes }\n\nOn every PATCH or DELETE we mirror the change to KV (D1 first per\nADR-0002). PATCH with status='paused' or 'revoked' deletes the KV\nkey so the worker stops serving the host without us having to delete\nthe D1 audit trail."
          },
          "response": []
        },
        {
          "name": "host    update mirror_target / status / etc",
          "request": {
            "method": "PATCH",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v1/admin/redirect-hosts/:host",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "admin",
                "redirect-hosts",
                ":host"
              ],
              "query": []
            },
            "description": "Same X-Service-Secret pattern as the index handler.\n\n  { mirror_target, mirror_status, prefix, product, tenant_id,\n    status, branding_json, notes }\n\nOn every PATCH or DELETE we mirror the change to KV (D1 first per\nADR-0002). PATCH with status='paused' or 'revoked' deletes the KV\nkey so the worker stops serving the host without us having to delete\nthe D1 audit trail.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"branding_json\": null,\n  \"mirror_status\": null,\n  \"mirror_target\": null,\n  \"notes\": null,\n  \"prefix\": null,\n  \"product\": null,\n  \"status\": null,\n  \"tenant_id\": null\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        },
        {
          "name": "host    remove (refuses if reserved=1)",
          "request": {
            "method": "DELETE",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/admin/redirect-hosts/:host",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "admin",
                "redirect-hosts",
                ":host"
              ],
              "query": []
            },
            "description": "Same X-Service-Secret pattern as the index handler.\n\n  { mirror_target, mirror_status, prefix, product, tenant_id,\n    status, branding_json, notes }\n\nOn every PATCH or DELETE we mirror the change to KV (D1 first per\nADR-0002). PATCH with status='paused' or 'revoked' deletes the KV\nkey so the worker stops serving the host without us having to delete\nthe D1 audit trail."
          },
          "response": []
        },
        {
          "name": "X-Service-Secret gated (pattern from functions/api/admin/enterprise.js)",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/admin/reservations",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "admin",
                "reservations"
              ],
              "query": []
            },
            "description": "X-Service-Secret gated (pattern from functions/api/admin/enterprise.js).\nCalled from internal admin surfaces (dashboard tooling, Linear automation,\nor ad-hoc `curl` with the secret). No user-session auth path.\n\nBody (POST):\n  {\n    shortcode: \"menu\",              // required, lowercased on write\n    reason: \"brand\" | \"profanity\" | \"internal\" | \"vanity_hold\" | ...,\n    category?: \"brand.tech\",\n    allows_sale?: bool,              // default false\n    notes?: \"Apple Inc. trademark hold\"\n  }\n\nQuery (GET):\n  ?category=brand.tech\n  ?reason=brand\n  ?q=app              (prefix match)\n  ?grantedOnly=1       (only currently-leased reservations)\n  ?limit=100&offset=0\n\nBody (DELETE):\n  { shortcode: \"menu\" }\n\nEvery mutation calls logAction() with actor_type=admin (see\nfunctions/lib/audit-log.js) so reservation changes are auditable even\nthough X-Service-Secret actions lack a user_id."
          },
          "response": []
        },
        {
          "name": "X-Service-Secret gated (pattern from functions/api/admin/enterprise.js)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v1/admin/reservations",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "admin",
                "reservations"
              ],
              "query": []
            },
            "description": "X-Service-Secret gated (pattern from functions/api/admin/enterprise.js).\nCalled from internal admin surfaces (dashboard tooling, Linear automation,\nor ad-hoc `curl` with the secret). No user-session auth path.\n\nBody (POST):\n  {\n    shortcode: \"menu\",              // required, lowercased on write\n    reason: \"brand\" | \"profanity\" | \"internal\" | \"vanity_hold\" | ...,\n    category?: \"brand.tech\",\n    allows_sale?: bool,              // default false\n    notes?: \"Apple Inc. trademark hold\"\n  }\n\nQuery (GET):\n  ?category=brand.tech\n  ?reason=brand\n  ?q=app              (prefix match)\n  ?grantedOnly=1       (only currently-leased reservations)\n  ?limit=100&offset=0\n\nBody (DELETE):\n  { shortcode: \"menu\" }\n\nEvery mutation calls logAction() with actor_type=admin (see\nfunctions/lib/audit-log.js) so reservation changes are auditable even\nthough X-Service-Secret actions lack a user_id.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"allows_sale\": null,\n  \"category\": null,\n  \"notes\": null,\n  \"reason\": null,\n  \"shortcode\": null\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        },
        {
          "name": "X-Service-Secret gated (pattern from functions/api/admin/enterprise.js)",
          "request": {
            "method": "DELETE",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/admin/reservations",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "admin",
                "reservations"
              ],
              "query": []
            },
            "description": "X-Service-Secret gated (pattern from functions/api/admin/enterprise.js).\nCalled from internal admin surfaces (dashboard tooling, Linear automation,\nor ad-hoc `curl` with the secret). No user-session auth path.\n\nBody (POST):\n  {\n    shortcode: \"menu\",              // required, lowercased on write\n    reason: \"brand\" | \"profanity\" | \"internal\" | \"vanity_hold\" | ...,\n    category?: \"brand.tech\",\n    allows_sale?: bool,              // default false\n    notes?: \"Apple Inc. trademark hold\"\n  }\n\nQuery (GET):\n  ?category=brand.tech\n  ?reason=brand\n  ?q=app              (prefix match)\n  ?grantedOnly=1       (only currently-leased reservations)\n  ?limit=100&offset=0\n\nBody (DELETE):\n  { shortcode: \"menu\" }\n\nEvery mutation calls logAction() with actor_type=admin (see\nfunctions/lib/audit-log.js) so reservation changes are auditable even\nthough X-Service-Secret actions lack a user_id."
          },
          "response": []
        },
        {
          "name": "Batch-grant reserved slugs to an Enterprise Scale customer as vanity",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v1/admin/reservations/grant",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "admin",
                "reservations",
                "grant"
              ],
              "query": []
            },
            "description": "Batch-grant reserved slugs to an Enterprise Scale customer as vanity\nredirects. Pattern: Apple Inc. signs an Enterprise Scale contract that\nincludes apple, appl, apl as vanity slots \u2192 admin posts this endpoint\nonce with all three slugs \u2192 each becomes an active `codes` row on\nApple's data-plane shard pointing to Apple's destination URL.\n\nX-Service-Secret gated. Always audit-logs per slug.\n\nBody:\n  {\n    user_id: \"U_apple\",                // target grantee (Enterprise Scale)\n    contract_id: \"ENT-2026-0047\",      // groups related grants for revoke\n    shortcodes: [\"apple\", \"appl\", \"apl\"],\n    destination_url: \"https://apple.com\",\n    expires_at?: 1782000000,           // unix seconds; null/omitted = evergreen\n    label?: \"Apple vanity\"             // optional codes.label (defaults to shortcode)\n  }\n\nFlow per slug:\n  1. Verify reservation exists, allows_sale=1, not granted to someone else\n  2. Flip reservation fields (control plane): granted_to_user_id/at/expires/contract\n  3. INSERT row into target user's data-plane shard\n     (codes table, custom_slug_from_reservation=1, status=active)\n  4. PUT KV entry sc:{slug} so the worker can resolve\n  5. Increment users.custom_slugs_in_use += 1\n  6. Audit log\n\nOn per-slug failure:\n  - Best-effort rollback: mark_reservation_released() + attempt DELETE on\n    the data-plane row + DELETE KV entry. If any rollback step also fails,\n    we log LOUD and proceed, the nightly orphan-grant sweeper will clean\n    up stuck state.\n  - Response always contains a per-slug status array so the operator\n    knows what landed and what didn't.\n\nResponse shape:\n  {\n    ok: bool,                // true only if every slug succeeded\n    contract_id,\n    user_id,\n    granted: [\"apple\", \"appl\"],\n    failed: [{ shortcode: \"apl\", reason: \"not_sellable\" }],\n  }",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"contract_id\": null,\n  \"destination_url\": null,\n  \"expires_at\": null,\n  \"label\": null,\n  \"shortcodes\": null,\n  \"user_id\": null\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        },
        {
          "name": "Revoke an Enterprise Scale vanity grant. Two call shapes:",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v1/admin/reservations/revoke",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "admin",
                "reservations",
                "revoke"
              ],
              "query": []
            },
            "description": "Revoke an Enterprise Scale vanity grant. Two call shapes:\n\n  1. By contract_id, revoke ALL slugs granted under a contract:\n     { contract_id: \"ENT-2026-0047\", grace_seconds?: 86400 }\n\n  2. By shortcode, revoke a single slug:\n     { shortcode: \"apple\", grace_seconds?: 86400 }\n\nSemantics:\n  - For each target slug:\n      a) Transition the grantee's code row to status='grace' with\n         grace_until = now + grace_seconds.\n      b) Update the KV entry to { status: 'grace' } so the worker\n         serves the grace page rather than the old redirect.\n      c) Clear the grant fields on shortcode_reservations\n         (granted_to_user_id \u2192 NULL, etc.) so the reservation row\n         returns to \"held, not leased\" state. The reservation itself\n         is NOT deleted, the slug remains reserved for future sales.\n  - Once grace_until passes, the nightly sweeper (scheduled.js) will\n    hard-delete the code row + KV entry and decrement the user's\n    custom_slugs_in_use counter.\n  - grace_seconds=0 revokes immediately (hard-delete on this call,\n    no grace period). Useful for trademark takedowns.\n\nX-Service-Secret gated. Always audit-logs per slug.\n\nResponse:\n  {\n    ok: bool,\n    revoked: [\"apple\", \"appl\"],\n    failed: [{ shortcode: \"apl\", reason: \"not_granted\" }],\n  }",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"contract_id\": null,\n  \"grace_seconds\": null,\n  \"shortcode\": null\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        },
        {
          "name": "Admin SLA endpoints, service-secret-authed",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/admin/sla",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "admin",
                "sla"
              ],
              "query": []
            },
            "description": "Admin SLA endpoints, service-secret-authed.\n\n  Record a support-ticket event. Unix seconds for timestamps.\n  responded_at = null \u2192 still open.\n\n  Rollup: per-plan median + p90 response time, count of open tickets,\n  count of tickets exceeding the <24h Agency commitment."
          },
          "response": []
        },
        {
          "name": "Admin SLA endpoints, service-secret-authed",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v1/admin/sla",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "admin",
                "sla"
              ],
              "query": []
            },
            "description": "Admin SLA endpoints, service-secret-authed.\n\n  Record a support-ticket event. Unix seconds for timestamps.\n  responded_at = null \u2192 still open.\n\n  Rollup: per-plan median + p90 response time, count of open tickets,\n  count of tickets exceeding the <24h Agency commitment.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"email\": null,\n  \"plan_at_time\": null,\n  \"received_at\": null,\n  \"responded_at\": null,\n  \"subject\": null\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        },
        {
          "name": "Fires status-subscriber outage emails for a specified set of down",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v1/admin/status-notify",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "admin",
                "status-notify"
              ],
              "query": []
            },
            "description": "Fires status-subscriber outage emails for a specified set of down\ncomponents. Called from qr-redirect-worker when a new incident is\nopened (the worker detects \"component just went down\" via the\nINSERT OR IGNORE meta.changes signal).\n\nAuth: X-Service-Secret header must match env.ABUNDERA_SERVICE_SECRET.\n\nBody: { components: [ { component: string, reason?: string }, ... ] }\nResponse: { ok: true, sent: <int>, errors: <array> }\n\nIdempotency: callers are expected to debounce, one call per new\nincident (not per poll tick). The worker only calls this when an\nincident row was freshly inserted, so repeat pollings of the same\nopen incident don't re-spam subscribers.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"components\": null\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        },
        {
          "name": "update  \u2192 create a new status update",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v1/admin/status-update",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "admin",
                "status-update"
              ],
              "query": []
            },
            "description": "Auth: X-Service-Secret must match env.ABUNDERA_SERVICE_SECRET.\n\nFields:\n  severity  , \"P1\" | \"P2\" | \"P3\" | \"P4\"\n  component , free-text label (\"redirect\", \"api\", \"dashboard\", ...)\n  status    , \"investigating\" | \"identified\" | \"monitoring\" | \"resolved\"\n  title     , short headline shown on the status page\n  message   , markdown-flavored body (plain text fine)\n  created_by, admin identifier; any non-empty string\n\nThe public /api/status aggregator surfaces these rows via the\nshared status-api lib. P1/P2 non-resolved rows force major_outage /\ndegraded. Resolved rows drop out after 7 days.\n\nNo D1-admin UI for launch. Operations via curl or the internal\nrunbook. A web UI lives as post-launch work.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"component\": null,\n  \"created_by\": null,\n  \"id\": null,\n  \"message\": null,\n  \"severity\": null,\n  \"status\": null,\n  \"title\": null\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        },
        {
          "name": "update  \u2192 update an existing update (by id)",
          "request": {
            "method": "PATCH",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v1/admin/status-update",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "admin",
                "status-update"
              ],
              "query": []
            },
            "description": "Auth: X-Service-Secret must match env.ABUNDERA_SERVICE_SECRET.\n\nFields:\n  severity  , \"P1\" | \"P2\" | \"P3\" | \"P4\"\n  component , free-text label (\"redirect\", \"api\", \"dashboard\", ...)\n  status    , \"investigating\" | \"identified\" | \"monitoring\" | \"resolved\"\n  title     , short headline shown on the status page\n  message   , markdown-flavored body (plain text fine)\n  created_by, admin identifier; any non-empty string\n\nThe public /api/status aggregator surfaces these rows via the\nshared status-api lib. P1/P2 non-resolved rows force major_outage /\ndegraded. Resolved rows drop out after 7 days.\n\nNo D1-admin UI for launch. Operations via curl or the internal\nrunbook. A web UI lives as post-launch work.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"component\": null,\n  \"created_by\": null,\n  \"id\": null,\n  \"message\": null,\n  \"severity\": null,\n  \"status\": null,\n  \"title\": null\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        }
      ]
    },
    {
      "name": "analytics",
      "item": [
        {
          "name": "Get scan analytics for a QR code",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/codes/:id/analytics?range=30d&granularity=day",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "codes",
                ":id",
                "analytics"
              ],
              "query": [
                {
                  "key": "range",
                  "value": "30d",
                  "description": "Requested time range. Silently capped to the plan's maximum retention window.",
                  "disabled": true
                },
                {
                  "key": "granularity",
                  "value": "day",
                  "description": "Bucket size for the timeseries. Hourly granularity requires Team or Agency plan and is limited to a 7-day window.",
                  "disabled": true
                }
              ]
            },
            "description": "Returns aggregated scan analytics for a single QR code owned by the authenticated user or their team.\n\nSupports configurable time ranges (7d, 30d, 90d, 1y, 3y) capped by the user's plan retention limit. Daily granularity is available on all plans; hourly granularity requires Team or Agency tier and is capped at a 7-day window.\n\nThe response includes a zero-filled timeseries, per-country breakdown, per-device breakdown, per-city breakdown (top 50), and a total scan count. Country, device, and city dimensions are always drawn from daily rollup data regardless of the requested granularity. All dimensional aggregates are passed through k-anonymity noise-floor suppression, small cohorts are folded into an \"Other\" bucket.\n\nScope: if the user belongs to a team, any team member may read analytics for any code owned by that team. Otherwise only the code owner may access the data.\n\nAuthentication: bearer JWT required (reads `data.user` set by auth middleware).\n"
          },
          "response": []
        }
      ]
    },
    {
      "name": "audit",
      "item": [
        {
          "name": "Returns recent `audit_log` rows for whichever scope the user is",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/audit",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "audit"
              ],
              "query": []
            },
            "description": "Returns recent `audit_log` rows for whichever scope the user is\ncurrently acting in (personal or team), matching how /api/codes\nand /api/webhooks resolve scope. Used by the federated\n/account/audit/ surface on abundera.ai (ADR 081); the bridge at\nabundera.ai/auth/audit?product=qrpro forwards the caller's cookie\nto this handler so the product enforces its own auth.\n\nQuery:\n  ?limit=N       page size (1-200, default 50)\n  ?offset=N      zero-based offset (default 0)\n  ?from=ISO      lower-bound ISO 8601 timestamp (inclusive)\n  ?to=ISO        upper-bound ISO 8601 timestamp (exclusive)\n  ?action=str    substring match on `action` column (case-insensitive)\n  ?actor=str     substring match on actor email (case-insensitive)\n  ?sort=asc|desc default desc\n\nReturns { entries, total, limit, offset, scope }."
          },
          "response": []
        }
      ]
    },
    {
      "name": "auth",
      "item": [
        {
          "name": "Redirect to login page (logout via GET)",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/auth/logout",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "auth",
                "logout"
              ],
              "query": []
            },
            "description": "Redirects the caller to `https://abundera.ai/login`. There is no server-side session\nto invalidate, logout is handled client-side by the browser (cookie clearing). This\nendpoint exists so non-browser API callers and any GET-based logout links get a\nconsistent redirect rather than a 404.\n\nNo authentication is required. No request body is read. No cookies or tokens are\ninspected or cleared server-side.\n"
          },
          "response": []
        },
        {
          "name": "Redirect to login page (logout via POST)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v1/auth/logout",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "auth",
                "logout"
              ],
              "query": []
            },
            "description": "Redirects the caller to `https://abundera.ai/login`. There is no server-side session\nto invalidate, logout is handled client-side by the browser (cookie clearing). This\nendpoint exists so non-browser API callers that POST a logout action get a consistent\nredirect rather than a 404.\n\nNo authentication is required. No request body is read. No cookies or tokens are\ninspected or cleared server-side.\n",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"data\": null\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        },
        {
          "name": "Redirect to login page (logout via DELETE)",
          "request": {
            "method": "DELETE",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/auth/logout",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "auth",
                "logout"
              ],
              "query": []
            },
            "description": "Redirects the caller to `https://abundera.ai/login`. There is no server-side session\nto invalidate, logout is handled client-side by the browser (cookie clearing). This\nendpoint mirrors the POST and GET variants for REST clients that issue DELETE for\nsession teardown.\n\nNo authentication is required. No request body is read. No cookies or tokens are\ninspected or cleared server-side.\n"
          },
          "response": []
        },
        {
          "name": "Get current authenticated user's profile",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/auth/me",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "auth",
                "me"
              ],
              "query": []
            },
            "description": "Returns the authenticated user's profile assembled from the local D1 mirror and\nselected claims from the abundera.ai JWT. Middleware upstream guarantees that\n`context.data.user` and `context.data.jwt` are populated before this handler\nruns, unauthenticated requests are rejected before reaching this function.\n\nThe response includes plan status, billing identifiers, free-tier verification\nunlock signals, and optional workspace/team context. Stripe fields are null for\nusers who have never entered a paid flow. The `active_codes_count` field allows\nthe account page to render a usage meter without a second round-trip.\n\nNo side effects. No rate limiting applied at this handler layer.\n"
          },
          "response": []
        },
        {
          "name": "RBAC introspection for the current credential",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/auth/whoami",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "auth",
                "whoami"
              ],
              "query": []
            },
            "description": "Side-effect-free. Returns `{ authenticated, product, user_id, email,\nname, auth_source, role, tier, scopes, key, rate_limit }` for the\npresented JWT/session/API key, or `{ authenticated:false, reason }`\n(200) when absent/invalid. Never cached.\n"
          },
          "response": []
        }
      ]
    },
    {
      "name": "billing",
      "item": [
        {
          "name": "Create a Stripe Checkout Session for a subscription or one-time payment",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v1/billing/checkout",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "billing",
                "checkout"
              ],
              "query": []
            },
            "description": "Initiates a Stripe Checkout Session for the authenticated user, returning a\nredirect URL to the hosted Stripe payment page.\n\nThe endpoint supports two checkout modes:\n- **subscription** (default): used for recurring plans (Solo, Business, Team,\n  Agency, Keep-Alive monthly/annual). Stripe will reject a non-recurring price\n  passed through this path.\n- **payment**: used for one-time purchases (Keep-Alive Decade). The caller must\n  explicitly pass `mode: \"payment\"` to trigger this path.\n\nIf the user does not yet have a Stripe customer record, one is created via the\nfederated abundera.ai customer service and the resulting `stripe_customer_id` is\npersisted back to the D1 `users` table.\n\n**Grace carry-over**: if the user is currently within a 90-day post-cancellation\ngrace window and is purchasing a Keep-Alive product, the remaining grace days are\napplied as a Stripe `trial_period_days` (for subscriptions) or passed as\n`grace_carry_over_days` metadata (for one-time payments, applied at webhook time\nby extending `keepalive_expires_at`). This ensures users are not double-charged\nfor access they were already promised.\n\nAuthentication is required. The handler reads `data.user` populated by upstream\nmiddleware; requests without a valid session will not reach this handler.\n\nRequires the `STRIPE_SECRET_KEY` environment variable to be configured; returns\n503 otherwise. Stripe errors propagate with their original HTTP status codes.\n",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"price_id\": \"price_1OqXxxLkdIwHu7ixAbCdEfGh\",\n  \"mode\": \"subscription\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        },
        {
          "name": "Create a public Stripe Checkout session for a subscription",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v1/billing/checkout-public",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "billing",
                "checkout-public"
              ],
              "query": []
            },
            "description": "Unauthenticated entry point for the \"payment-as-invite\" cold-signup flow. A\nvisitor who has no account hits the pricing CTA and this endpoint hands back\na Stripe-hosted Checkout URL. No bearer token or session cookie is required;\nthe endpoint is explicitly exempted from the authentication middleware via\nPUBLIC_API_PATHS.\n\nOn successful Stripe Checkout completion the webhook calls the abundera.ai\nservice with `create_invite: true`, which mints a registration invite and\nsends an activation email to the address Stripe collected (either supplied\nhere or entered on the hosted page).\n\nOnly subscription-mode prices are accepted. One-time payment prices (e.g.\nthe Keep-Alive Decade product) require a signed-in account and will be\nrejected with `payment_mode_requires_signed_in`.\n\n**Coupon handling:** Only coupons explicitly whitelisted server-side\n(currently `FOUNDING_LAUNCH_2026`) are attached to the session. Any other\ncoupon value supplied by the client is silently dropped and the buyer pays\nfull price; the checkout is not aborted.\n\n**Rate limits:**\n- Per client IP: 20 requests/hour for standard (full-price) purchases;\n  5 requests/hour for cohort-discounted purchases (i.e. when the\n  `FOUNDING_LAUNCH_2026` coupon is requested).\n- Per email address (only when `email` is provided): 3 requests/hour.\n- Rate-limit infrastructure failures are fail-open so a KV outage never\n  blocks a legitimate purchase.\n\n**Email validation:** When `email` is provided and the service secret is\nconfigured, the address is checked against the abundera.ai email-validate\nservice (disposable domains, role prefixes, typos). This call is also\nfail-open; Stripe Radar acts as a secondary defence.\n\n**Abuse guard:** Cohort-discounted purchases additionally run a\nrefund/chargeback blocklist check before the Stripe API call. Full-price\npurchases skip this check entirely.\n\nSide effects on success: a Stripe Checkout Session is created and a row is\nwritten to the internal `stripe_events_log` store recording the outcome,\nsession ID, cohort, email, and request duration.\n",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"email\": \"string\",\n  \"price_id\": \"string\",\n  \"coupon\": \"string\",\n  \"mode\": \"string\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        },
        {
          "name": "Create a Stripe Customer Portal session for self-serve billing management",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v1/billing/portal",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "billing",
                "portal"
              ],
              "query": []
            },
            "description": "Generates a short-lived Stripe-hosted Customer Portal URL for the authenticated\nuser, allowing them to self-serve plan changes, cancellations, payment method\nupdates, and invoice downloads. The portal session redirects back to the account\npage at https://pro.qr.abundera.ai/account/ on completion.\n\nAuthentication is required. The handler reads the resolved user from the request\ncontext (`data.user`), which is populated by upstream authentication middleware\nthat validates a bearer JWT. Unauthenticated requests will be rejected before\nreaching this handler.\n\nThe user must have previously completed at least one checkout via\n`/api/billing/checkout` so that a `stripe_customer_id` is on record. Users\nwithout a Stripe customer ID will receive a 400 error.\n\nThe endpoint delegates session creation to Stripe's API using the configured\n`STRIPE_SECRET_KEY` environment variable. If that variable is absent the\nendpoint returns 503. Any error propagated by the Stripe SDK is surfaced as a\nstripe_error response, with the HTTP status taken from the Stripe error object\nwhen available, falling back to 500.\n\nNo quota or rate-limit logic is implemented within this handler itself; limits\nare governed by Stripe's API and any platform-level Cloudflare rate rules.\n",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"data\": null\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        },
        {
          "name": "Retrieve sanitized Stripe Checkout Session for post-checkout modal",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/billing/session?session_id=string",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "billing",
                "session"
              ],
              "query": [
                {
                  "key": "session_id",
                  "value": "string",
                  "description": "Stripe Checkout Session ID. Must begin with \"cs_\".",
                  "disabled": false
                }
              ]
            },
            "description": "Reads a Stripe Checkout Session by its session ID and returns a sanitized\nsubset of fields suitable for rendering a post-checkout success modal (ADR 084).\n\nAuth model: public, no end-user authentication required. The session_id\nparameter itself serves as the auth token, it is a random, single-use,\nshort-lived value issued by Stripe.\n\nHardening rules enforced server-side:\n- Rejects any session_id that does not begin with \"cs_\".\n- Rejects sessions where payment_status is not \"paid\" (prevents leaking\n  names or prices for abandoned checkouts).\n- Rejects sessions older than 1 hour (replay window protection).\n\nOn Stripe API failure the handler returns 404 rather than 500, keeping\nthe client's graceful fallback path tight.\n\nSide effects: every lookup attempt (success, skip, or failure) is written\nto the Stripe event log via logStripeEvent.\n"
          },
          "response": []
        }
      ]
    },
    {
      "name": "byo",
      "item": [
        {
          "name": "Register or update a BYO delegation endpoint for a QR code",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v1/byo/register",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "byo",
                "register"
              ],
              "query": []
            },
            "description": "Registers a Bring-Your-Own (BYO) delegation endpoint and Ed25519 trust anchor\nfor a QR code owned by the authenticated user, implementing Patent QR-12 \u00a76.2\nprovisioning. Once registered, scan resolution for the code is delegated to the\ncustomer-controlled HTTPS endpoint; the centralized destination URL stored in the\nplatform is no longer authoritative for that code.\n\nIf a delegation record already exists for the given code, the operation performs\nan upsert: it overwrites the delegation URL, trust anchor public key, resets the\nsequence high-water mark to zero, and clears any cached destination URL and cache\nexpiry. The updated record is also mirrored into KV storage so the edge scan-time\nresolver picks up the change immediately.\n\nAuthentication is required. The caller must present a valid bearer JWT. The handler\nverifies that the authenticated user owns the target code by checking the `user_id`\ncolumn in the `codes` table; callers who do not own the code receive a 403 response.\n\nThe `delegation_url` must be an HTTPS URL (scheme check enforced). The\n`trust_anchor_pub` must be a base64-encoded raw Ed25519 public key (32 bytes),\nthough byte-length validation is not enforced server-side beyond type checking.\n\nNo explicit rate limits are documented in the handler code.\n",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"code_id\": \"string\",\n  \"delegation_url\": \"string\",\n  \"trust_anchor_pub\": \"string\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        },
        {
          "name": "Resolve current destination URL for a BYO-delegated QR code",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/byo/resolve?code_id=string",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "byo",
                "resolve"
              ],
              "query": [
                {
                  "key": "code_id",
                  "value": "string",
                  "description": "The BYO code identifier to resolve. Must correspond to a registered delegation row.",
                  "disabled": false
                }
              ]
            },
            "description": "Fetches the current destination URL for a QR code that uses BYO (Bring Your Own) delegation.\nImplements pull-mode delegated resolution per patent QR-12 \u00a76.3 and \u00a76.7.\n\nOn each call, the handler first checks whether a cached destination is still valid (per the\nstored TTL). If so, it returns the cached URL immediately without contacting the customer's\ndelegation endpoint. Otherwise, it fetches the delegation endpoint, validates the Ed25519\nsignature against the registered trust anchor, and bumps the sequence high-water mark to\nprevent replay attacks (\u00a76.10).\n\nOn a successful live fetch, the resolved destination and updated sequence number are written\nback to D1 and mirrored to KV for the worker's scan path.\n\nNo authentication is required for this endpoint. The trust anchor validation at the delegation\nlayer is the integrity mechanism.\n"
          },
          "response": []
        }
      ]
    },
    {
      "name": "codes",
      "item": [
        {
          "name": "List QR codes in current scope",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/codes?page=1&page_size=25&q=string&tag=string&status=active&sort=created_desc",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "codes"
              ],
              "query": [
                {
                  "key": "page",
                  "value": "1",
                  "description": "1-indexed page number. Defaults to 1.",
                  "disabled": true
                },
                {
                  "key": "page_size",
                  "value": "25",
                  "description": "Number of codes per page. Defaults to 25, maximum 100.",
                  "disabled": true
                },
                {
                  "key": "q",
                  "value": "string",
                  "description": "Full-text search across label, tags, shortcode, and destination URL (LIKE match, escaped).",
                  "disabled": true
                },
                {
                  "key": "tag",
                  "value": "string",
                  "description": "Filter by exact whole-token tag value.",
                  "disabled": true
                },
                {
                  "key": "status",
                  "value": "active",
                  "description": "Filter by code lifecycle status.",
                  "disabled": true
                },
                {
                  "key": "sort",
                  "value": "created_desc",
                  "description": "Sort order for results.",
                  "disabled": true
                }
              ]
            },
            "description": "Returns a paginated, filterable list of dynamic QR codes belonging to the authenticated user's current scope.\n\nScope is resolved automatically: if the user is on a Team plan with an active team membership, codes are team-scoped (any member may list); otherwise codes are user-scoped (Solo, Business, or personal workspace).\n\nAll query parameters are optional. Results include a `short_url` field derived from each code's shortcode. The response also surfaces the resolved scope, the user's plan, and the plan's code limit (null for unlimited plans).\n\nRequires a valid bearer JWT (middleware sets `data.user`).\n"
          },
          "response": []
        },
        {
          "name": "Create a new dynamic QR code",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v1/codes",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "codes"
              ],
              "query": []
            },
            "description": "Creates a new dynamic QR code in the authenticated user's current scope.\n\nScope is resolved the same way as GET: team-scoped when the user is on a Team plan with an active membership (admin role required to create); otherwise user-scoped.\n\nThe destination URL is required. All other fields are optional. A shortcode is auto-generated unless `custom_slug` is provided. Custom slugs require a paid plan, a minimum length determined by plan tier, and a per-plan cap on the number of custom slugs.\n\nFree-tier accounts are subject to additional guardrails before the code is written:\n- Rate limit: 5 codes per hour and 20 codes per day per user. Exceeding either returns 429.\n- Safe Browsing check: if `GOOGLE_SAFE_BROWSING_API_KEY` is configured, the destination URL is checked against Google Safe Browsing. A positive match returns 400 with `error: destination_flagged`.\n\nPaid tiers bypass both the rate limit and the Safe Browsing check.\n\nKeep-Alive plan accounts cannot create new codes and receive 402 with `error: plan_cannot_create`.\n\nRequires a valid bearer JWT (middleware sets `data.user`).\n",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"url\": \"https://example.com\",\n  \"label\": \"string\",\n  \"tags\": [\n    \"string\"\n  ],\n  \"qr_type\": \"string\",\n  \"qr_data\": {},\n  \"style_json\": {},\n  \"logo_key\": \"string\",\n  \"logo_svg\": \"string\",\n  \"frame_style\": \"string\",\n  \"frame_text\": \"string\",\n  \"export_format\": \"string\",\n  \"starts_at\": \"2026-05-17T00:00:00Z\",\n  \"expires_at\": \"2026-05-17T00:00:00Z\",\n  \"password\": \"string\",\n  \"custom_slug\": \"string\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        },
        {
          "name": "Perform bulk operations on multiple QR codes",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v1/codes/bulk",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "codes",
                "bulk"
              ],
              "query": []
            },
            "description": "Applies a single action to a batch of up to 500 QR codes in one request. Supported actions\nare: add_tag, remove_tag, set_tags, move, set_group, archive, restore, and delete.\n\nThe caller's scope is resolved automatically. In team scope, admin-level membership is required\nfor tag operations (add_tag, remove_tag, set_tags), archive, restore, and set_group; owner-level\nmembership is required for the move action. In personal scope all actions are permitted on the\ncaller's own codes.\n\nEach code ID in the batch is validated against the caller's current scope to prevent insecure\ndirect object references. IDs that cannot be found or fall outside scope are returned in the\nskipped array rather than aborting the entire batch, so valid rows are always processed.\n\nFor archive and delete actions the code status is set to 'grace' and a 90-day countdown\n(grace_until) is recorded. The restore action reverses this if the grace window has not yet\nexpired. Both status transitions are propagated to Cloudflare KV on a best-effort basis so that\nredirect behaviour stays consistent; KV failures are logged but do not roll back the D1 write.\n\nThe move action reassigns codes to a different team. The caller must own both the source and\ndestination teams. Passing null for team_id moves codes to the caller's personal scope.\n\nThe account must be in a writable state (requireWritableAccount). A valid bearer JWT is required.\n",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"action\": \"add_tag\",\n  \"ids\": [\n    \"string\"\n  ],\n  \"args\": {\n    \"tag\": \"string\",\n    \"tags\": \"string\",\n    \"team_id\": \"string\",\n    \"group_id\": \"string\"\n  }\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        },
        {
          "name": "Check if a custom shortcode slug is available",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/codes/check-slug?slug=string",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "codes",
                "check-slug"
              ],
              "query": [
                {
                  "key": "slug",
                  "value": "string",
                  "description": "The candidate shortcode slug to check. Omitting or sending an empty value returns available=false with reason=invalid.",
                  "disabled": true
                }
              ]
            },
            "description": "Preflight availability check for custom shortcode input fields. Returns an\navailability verdict so the UI can render inline feedback as the user types.\n\nDoes not claim the slug. The only mutation path is POST /api/codes.\n\nAuth is required, the middleware must populate data.user before this handler\nruns. Unauthenticated requests receive a 401.\n\nNo rate-limit charge is applied. This is a cheap read endpoint intended for\nUX polish, safe to call on every keystroke.\n\nWhen available is false, the reason field explains why:\n- invalid, empty input, bad alphabet, consecutive hyphens, or wrong length\n- plan_not_eligible, the user's plan (Free or KA) does not support custom slugs\n- tier_too_short, the slug is shorter than the user's plan minimum\n- cap_reached, the user has reached their tier's maximum custom slug count\n- reserved, the slug is in the reservations table and not granted to this user\n- taken, the slug already exists in KV (claimed by someone else)\n\nA reservation granted to the requesting user counts as available from that\nuser's perspective.\n"
          },
          "response": []
        },
        {
          "name": "Derive the deterministic shortcode for a URL without registering it",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v1/codes/derive-shortcode",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "codes",
                "derive-shortcode"
              ],
              "query": []
            },
            "description": "Computes and returns the shortcode that would be assigned to the given destination URL\nunder the authenticated user's identity, without persisting any record or consuming any\nquota. The derivation is fully deterministic: given the same URL and user identity the\nsame shortcode is always produced, provided the server-side deterministic salt\n(`SHORTCODE_DETERMINISTIC_SALT`) is configured.\n\nThis endpoint backs the verification-without-lookup property described in Patent QR-03.\nA caller or third-party auditor holding a claimed `(shortcode, url, userId)` tuple can\nconfirm its authenticity by calling this endpoint with the claimed URL and comparing the\nreturned shortcode against the claimed one.\n\nAuthentication is required. The handler reads `data.user.id` (populated by upstream\nmiddleware) to scope the derivation to the requesting user. Requests without a valid\nauthenticated session will be rejected before reaching this handler.\n\nIf the `SHORTCODE_DETERMINISTIC_SALT` environment variable is not configured on the\nserver, the endpoint returns `501 Not Implemented`. No shortcode is stored and no side\neffects occur regardless of the outcome.\n",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"url\": \"string\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        },
        {
          "name": "Import a free-site QR design as a new Pro dynamic code",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v1/codes/import",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "codes",
                "import"
              ],
              "query": []
            },
            "description": "Accepts a design state payload from the qr.abundera.ai free site and creates a\nnew Pro dynamic QR code owned by the authenticated user. This is the server-side\nentry point for the \"Save as Dynamic\" flow, typically invoked from the /go/save/\nlanding page.\n\nThe handler resolves the destination URL, normalises all visual design fields\n(style, logo, frame), generates a UUID for the new code record and a unique\nshortcode, writes the code row to the user's shard database, and publishes the\nshortcode-to-URL mapping to KV. On success it returns the new code's identifiers\nand all normalised fields so the caller can redirect the user straight to the\nedit page.\n\nAuthentication is enforced by upstream middleware which populates `data.user`;\nthe handler additionally calls `requireWritableAccount` to reject suspended or\nread-only accounts. `enforceCodeLimit` is called before insertion and will return\nan error if the user has reached their plan's code quota. No explicit rate limit\nbeyond the plan quota is applied at this layer.\n\nSide effects: one row inserted into the `codes` table in the user's shard\ndatabase; one KV key written for the shortcode resolver.\n",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"destination\": \"string\",\n  \"url\": \"string\",\n  \"style\": null,\n  \"style_json\": null,\n  \"logo_key\": \"string\",\n  \"logo_svg\": \"string\",\n  \"frame_style\": \"string\",\n  \"frame_text\": \"string\",\n  \"export_format\": \"string\",\n  \"label\": \"string\",\n  \"tags\": \"string\",\n  \"v\": 0\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        },
        {
          "name": "Evaluate preflight verdict for QR code stylistic parameters",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v1/codes/preflight",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "codes",
                "preflight"
              ],
              "query": []
            },
            "description": "Accepts a candidate set of QR code stylistic parameters and returns a\npreflight verdict indicating whether the combination is safe to proceed\nwith for rendering or export. This endpoint implements Patent QR-07 and\nis intended to be called by clients before invoking any server-side\nrender or export endpoint.\n\nThe handler does not perform any rendering, export, or persistent\nside-effects, it only evaluates the provided parameters against\npreflight rules defined in the shared `evaluatePreflight` utility.\nServer-side renderers are expected to gate via `gateExportOrThrow()`\nindependently; this endpoint exposes the same verdict to clients so\nthey can surface feedback early.\n\nNo authentication is required by this handler. There are no documented\nrate limits or quotas enforced at the handler level.\n",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"fgColor\": \"string\",\n  \"bgColor\": \"string\",\n  \"errorCorrection\": \"string\",\n  \"logoCoverageFraction\": 0,\n  \"quietZoneModules\": 0,\n  \"finderPatternIntegrity\": 0,\n  \"timingPatternIntegrity\": 0\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        },
        {
          "name": "Solve QR + fallback-text layout for a commercial label format",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v1/codes/print-sheet",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "codes",
                "print-sheet"
              ],
              "query": []
            },
            "description": "Computes a per-cell layout solution for placing a QR code and an accessible\nfallback text string within a commercial label bounding box. The caller may\nidentify the target label by a named format (e.g. `avery-5160`) or supply an\nexplicit bounding box in points; exactly one of the two must be present.\n\nThe solver jointly determines the largest feasible QR module scale and\nfallback typographic scale that simultaneously satisfy three constraint sets:\nISO/IEC 18004 quiet-zone requirements, WCAG 2.2 AA minimum text size, and a\ndecode-distance minimum-module-size constraint derived from Patent QR-09.\nAn optional padding value in points is subtracted from all four sides of the\nbounding box before the solve.\n\nWhen a feasible solution exists the endpoint returns HTTP 200 with the full\nlayout geometry. When no feasible solution exists, for example because the\nbounding box is too small for the requested module count or the fallback text\ncannot meet WCAG minimums, the endpoint returns HTTP 422 with a structured\nviolation report describing which constraints were infeasible and by how much.\n\nNo authentication is required; the handler reads the request body directly\nwithout inspecting credentials or session state. No persistent side effects\nare produced; the operation is a pure computation.\n",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"labelFormat\": \"avery-5160\",\n  \"boundingBox\": {\n    \"width_pt\": 0,\n    \"height_pt\": 0\n  },\n  \"fallbackText\": \"string\",\n  \"qrModuleCount\": 0,\n  \"padding_pt\": 0\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        },
        {
          "name": "Render a print-ready QR label sheet as a PDF",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v1/codes/print-sheet-pdf",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "codes",
                "print-sheet-pdf"
              ],
              "query": []
            },
            "description": "Generates a multi-cell, print-ready PDF sheet where each cell contains a QR\ncode image (supplied by the caller as a base64-encoded PNG) and an accessible\nfallback typographic representation. Cell positions are computed by the\nserver-side layout solver (Patent QR-09), which jointly satisfies ISO/IEC 18004\nquiet-zone requirements, WCAG 2.2 AA minimum text size, and decode-distance\nminimum module size.\n\nBefore any PDF is produced the request passes through the QR-07 export gate\n(gateExportOrThrow). If any styling parameter fails preflight, low contrast\nbetween foreground and background colours, error-correction capacity overflow\ncaused by logo coverage, or broken finder/timing patterns, the gate raises a\n422 with a structured violations array and PDF generation is aborted. There is\nno bypass for this gate.\n\nThe layout solver is invoked next. If the requested label format or bounding\nbox produces an infeasible layout (e.g. the QR module size falls below the\ndecode-distance minimum) a 422 is returned with a structured violations array\nbefore any PDF work begins.\n\nOn success the response body is a raw PDF binary (application/pdf) with a\nContent-Disposition attachment header suggesting the filename\n\"qr-print-sheet.pdf\". The response is not cached (Cache-Control: no-store).\n\nNo authentication check is performed by this handler; access control must be\nenforced upstream (e.g. at the Cloudflare Pages routing layer).\n",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"labelFormat\": \"avery-5160\",\n  \"boundingBox\": {\n    \"width_pt\": 0,\n    \"height_pt\": 0\n  },\n  \"fallbackText\": \"string\",\n  \"qrModuleCount\": 0,\n  \"qrPngB64\": \"string\",\n  \"padding_pt\": 0,\n  \"styleParams\": {\n    \"fgColor\": \"string\",\n    \"bgColor\": \"string\",\n    \"errorCorrection\": \"string\",\n    \"logoCoverageFraction\": 0,\n    \"quietZoneModules\": 0,\n    \"finderPatternIntegrity\": true,\n    \"timingPatternIntegrity\": true\n  },\n  \"pageSize\": \"letter\",\n  \"sheetCols\": 0,\n  \"sheetRows\": 0\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        },
        {
          "name": "Produce and anchor a signed issuance record for a QR code",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v1/codes/sign-issuance",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "codes",
                "sign-issuance"
              ],
              "query": []
            },
            "description": "Creates a canonical content hash for the supplied QR code parameters, signs it with the\nprovided Ed25519 private key (supplied as a JWK, in production this originates from an\nHSM-bound environment key; in staging it may be an in-memory keypair), and publishes the\nresulting signed record to at least two mutually-independent anchor repositories (Rekor\nand a TSA), satisfying the plurality requirement of Patent QR-02 claim 1.\n\nThis is the canonical RTP entry point for every code creation in production and must be\ncalled after `deriveShortcode()` has produced a valid short code. The issued timestamp is\nset server-side to the current Unix epoch (seconds) at the moment the request is processed.\n\nThe destination URL must be an absolute HTTPS URL; plain HTTP or protocol-relative URLs\nare rejected with a 400. All five body fields are required; missing or wrongly-typed\nfields are rejected before any cryptographic work is attempted.\n\nNo authentication header is enforced by this handler itself, access control is expected\nto be enforced at the network or gateway layer before requests reach this function. There\nare no explicit rate limits implemented in the handler code.\n\nSide effects: the signed issuance record is published to external anchor services (Rekor\ntransparency log and a TSA); these publications are irreversible.\n",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"short_code\": \"string\",\n  \"destination_url\": \"string\",\n  \"org_id\": \"string\",\n  \"private_key_jwk\": {},\n  \"public_key_b64\": \"string\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        },
        {
          "name": "List stale codes that haven't been scanned recently",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/codes/stale?days=90&limit=100",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "codes",
                "stale"
              ],
              "query": [
                {
                  "key": "days",
                  "value": "90",
                  "description": "Staleness threshold in days. Clamped to 7\u2013365. Defaults to 90.",
                  "disabled": true
                },
                {
                  "key": "limit",
                  "value": "100",
                  "description": "Maximum number of codes to return. Clamped to 1\u2013500. Defaults to 100.",
                  "disabled": true
                }
              ]
            },
            "description": "Returns active and paused codes that have never been scanned, or whose last scan is older\nthan the requested threshold. Codes in grace or expired states are excluded.\n\nThe `days` parameter sets the staleness window (clamped to 7\u2013365, default 90). The `limit`\nparameter caps the result set (clamped to 1\u2013500, default 100).\n\nUseful for library hygiene: identify codes worth archiving when they show no scan activity\nover a meaningful period.\n\nRequires a valid bearer JWT. The query runs against the data shard for the authenticated\nuser's resolved scope (personal or team).\n"
          },
          "response": []
        },
        {
          "name": "List distinct tags with counts for the caller's scope",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/codes/tags",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "codes",
                "tags"
              ],
              "query": []
            },
            "description": "Returns the tag facet for the authenticated caller's current scope: a list of distinct\ntags with their QR code counts, sorted by count descending (tiebreak: name ascending),\ncapped at 50 entries.\n\nThe response also includes `plain_tags` (unnamespaced tags) and `grouped_tags`\n(namespace-prefixed tags, grouped by prefix) derived from the same facet data.\n\nScope is resolved automatically: if the caller belongs to a team, the facet covers\nthat team's codes; otherwise it covers only the caller's personal codes.\n\nResults are KV-cached for 5 minutes per scope. Stale counts affect only the facet\nsidebar ordering, never correctness of code data. The `cached` field in the response\nindicates whether the result came from cache.\n\nRequires a valid bearer JWT. No explicit rate limit beyond the auth layer.\n"
          },
          "response": []
        },
        {
          "name": "Top-N leaderboard of most-scanned codes",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/codes/top?days=30&limit=10",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "codes",
                "top"
              ],
              "query": [
                {
                  "key": "days",
                  "value": "30",
                  "description": "Trailing window in days to aggregate scan counts over. Clamped to [1, 365], default 30.",
                  "disabled": true
                },
                {
                  "key": "limit",
                  "value": "10",
                  "description": "Maximum number of codes to return. Clamped to [1, 50], default 10.",
                  "disabled": true
                }
              ]
            },
            "description": "Returns the highest-scan codes for the caller's current scope over a configurable\ntrailing window. Drives the \"most active codes\" dashboard tile.\n\nScope is resolved from the authenticated user's team or personal account context.\nThe query runs against the appropriate shard database for that scope.\n\nBoth `days` and `limit` are clamped server-side: `days` to [1, 365] (default 30),\n`limit` to [1, 50] (default 10). Non-numeric or missing values fall back to defaults.\n\nRequires a valid bearer JWT. No write side effects.\n"
          },
          "response": []
        },
        {
          "name": "Fetch a single QR code by ID",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/codes/:id",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "codes",
                ":id"
              ],
              "query": []
            },
            "description": "Returns a single QR code record the authenticated user has access to. Scope is\ndetermined by the user's current team context: if the user has an active team\n(`current_team_id`), the code must belong to that team and any member may read\nit. Otherwise, only personal codes (those with no `team_id`) owned by the caller\nare accessible.\n\nRequires a valid bearer JWT. Returns the serialized code fields plus a\n`short_url` constructed from the code's shortcode.\n"
          },
          "response": []
        },
        {
          "name": "Update a QR code's fields or move it between scopes",
          "request": {
            "method": "PATCH",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v1/codes/:id",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "codes",
                ":id"
              ],
              "query": []
            },
            "description": "Partially updates a QR code. All body fields are optional; at least one\nmust be present or a `no_fields` error is returned.\n\n**Auth and scope**: requires a valid bearer JWT. Scope is resolved from the\ncaller's current team context. On a team plan, the caller must hold `admin`\nor `owner` role to mutate. Moving a code out of a team requires `owner` role\non the source team; moving into another team requires the caller to own that\ndestination team.\n\n**Keep-Alive plan restrictions**: destination URL edits may be blocked for\nKeep-Alive plan users (plan-specific policy). Design customization fields\n(`qr_type`, `qr_data`, `style_json`, `logo_key`, `logo_svg`, `frame_style`,\n`frame_text`, `export_format`) are entirely blocked on Keep-Alive; only URL,\nlabel, and tag edits are permitted.\n\n**Status constraints**: codes in `grace` or `expired` status cannot be edited.\n\n**Password**: send an empty string or `null` to clear the password gate; send\na non-empty string to set or rotate it. The verifier is never returned.\n\n**Schedule**: `starts_at` and `expires_at` are independently nullable ISO-8601\ntimestamps. After normalization, the pair is validated so `starts_at` < `expires_at`.\n\nOn success, writes an audit-log entry, fires any configured webhooks, and\nupdates the KV shortcode entry to reflect the new destination.\n",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"url\": \"string\",\n  \"label\": \"string\",\n  \"tags\": \"string\",\n  \"qr_type\": \"string\",\n  \"qr_data\": \"string\",\n  \"style_json\": \"string\",\n  \"logo_key\": \"string\",\n  \"logo_svg\": \"string\",\n  \"frame_style\": \"string\",\n  \"frame_text\": \"string\",\n  \"export_format\": \"string\",\n  \"status\": \"active\",\n  \"starts_at\": \"string\",\n  \"expires_at\": \"string\",\n  \"password\": \"string\",\n  \"team_id\": \"string\",\n  \"group_id\": \"string\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        },
        {
          "name": "Soft-delete a QR code (enters 90-day grace period)",
          "request": {
            "method": "DELETE",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/codes/:id",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "codes",
                ":id"
              ],
              "query": []
            },
            "description": "Soft-deletes a QR code by setting its status to `grace` and recording a\n`grace_until` timestamp 90 days in the future. The shortcode KV entry is\nupdated to reflect the grace state so the redirect worker can serve an\nappropriate response during the grace window. A hard delete is not performed\nimmediately.\n\n**Auth and scope**: requires a valid bearer JWT. On a team plan, the caller\nmust hold `admin` or `owner` role. Codes already in `grace` or `expired`\nstatus cannot be deleted again.\n\nOn success, writes an audit-log entry and fires any configured webhooks.\n"
          },
          "response": []
        },
        {
          "name": "Export analytics for a single QR code as CSV",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/codes/:id/analytics.csv?range=30d&granularity=day",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "codes",
                ":id",
                "analytics.csv"
              ],
              "query": [
                {
                  "key": "range",
                  "value": "30d",
                  "description": "Time window for the export. Mapped to 7, 30, 90, 365, or 1095 days respectively. The effective window is capped by the plan's analytics history limit.\n",
                  "disabled": true
                },
                {
                  "key": "granularity",
                  "value": "day",
                  "description": "Bucket size for the timeseries section. `hour` is only honoured when the caller's plan includes hourly analytics; otherwise `day` is used.\n",
                  "disabled": true
                }
              ]
            },
            "description": "Returns a multi-section CSV file containing scan analytics for the specified QR code over a\nrequested time window. The export mirrors the three sections shown in the dashboard UI:\ntimeseries (per-day or per-hour scan counts), scans by country, and scans by device type.\n\nThe authenticated user must own the code, either directly (no team) or via the resolved team\nscope. Returns 404 if the code is not found under the caller's scope.\n\nThe time window is controlled by the `range` query parameter. The actual window returned may\nbe shorter than requested if the caller's plan limits the analytics history (e.g. free plan\ncaps at 30 days). Hourly granularity is only available on plans that include it; otherwise the\nendpoint always returns daily buckets regardless of the `granularity` parameter.\n\nCountry and device rows have k-anonymity applied (same transformation as the JSON analytics\nendpoint, closing the CSV bypass path). Dimensional values below the threshold are folded into\na catch-all label.\n\nThe response carries `Content-Disposition: attachment` with a filename that includes the\nshortcode, range key, and export date so multiple downloads do not overwrite each other.\n`Cache-Control: no-store` is set on all responses.\n"
          },
          "response": []
        },
        {
          "name": "Get deep analytics stats for a single QR code",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/codes/:id/deep-stats",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "codes",
                ":id",
                "deep-stats"
              ],
              "query": []
            },
            "description": "Returns a comprehensive analytics payload for one QR code identified by `id`. The code must belong to the authenticated user's personal scope or a team they belong to; a 404 is returned if the code does not exist or is out of scope.\n\nThe response includes:\n- **narrative**, 2\u20134 sentence auto-synthesized insights summary\n- **deltas**, w7/w30/w90 scan windows with current, prior, and delta_pct, plus a 30-day daily sparkline array\n- **projection**, linear regression over the sparkline forecasting next-30-day scans, slope per day, and how many days the fit is based on (null if fewer than 7 data points)\n- **anomaly**, z-score of yesterday's scan count vs the 14-day baseline; null when the baseline is flat or the z-score is within \u00b13.0\n- **velocity**, current and prior 7-day per-day rate, growth percentage, tier label (viral/hot/normal/slow/dead), and estimated half-life days\n- **bullet**, actual vs target per-day rate derived from the trailing 23-day baseline, ratio, and band (good/ok/bad); null when the baseline is zero\n- **day_of_life**, daily scan series anchored at the first scan date (up to 60 days)\n- **yoy**, last 30 days vs the same 30-day window one year prior\n- **peer_benchmark**, this code's 30-day scan percentile vs peer codes in the same scope; null when the scope has five or fewer peers\n- **top_country**, top scanning country code to support weather-overlay calls\n\nRequires a valid bearer JWT. No explicit rate limit is applied at this endpoint beyond the auth layer.\n"
          },
          "response": []
        },
        {
          "name": "Fetch all heatmap datasets for a QR code",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/codes/:id/heatmaps?days=365",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "codes",
                ":id",
                "heatmaps"
              ],
              "query": [
                {
                  "key": "days",
                  "value": "365",
                  "description": "Number of days of history for the calendar dataset. Capped at 1095 and further capped by the caller's plan retention window. Invalid or missing values fall back to 365.\n",
                  "disabled": true
                }
              ]
            },
            "description": "Returns three privacy-safe binned analytics datasets for a single QR code in one round-trip, so the edit page can render calendar, hourly-DOW, and geo heatmaps without multiple requests.\n\n**Calendar** (`calendar`): daily scan counts for the requested window (default 365 days, max 1095). Zero-filled so the calendar grid is always dense. The window is capped by the caller's plan retention limit.\n\n**Hourly \u00d7 DOW grid** (`hourly_dow`): a 24\u00d77 matrix of scan counts by day-of-week (0=Sunday through 6=Saturday) and hour (0\u201323), derived from the `scans_hourly` table. Only populated for Team and Agency plans; Free and Pro callers receive an empty array. The DOW window is always the last 90 days regardless of the `days` parameter.\n\n**Geo** (`geo`): top countries by scan count. Countries below a noise floor of 5 scans are folded into an \"Other\" bucket. At most 50 country rows are returned before noise-floor folding.\n\nScope resolution delegates to `resolveCodeScope`, which grants access to any team member for team-owned codes and to the owning user for personal codes. The code must be visible in the caller's resolved scope or a 404 is returned.\n\nRequires a valid bearer JWT (the handler reads `data.user` populated by upstream auth middleware).\n"
          },
          "response": []
        },
        {
          "name": "Get lifetime campaign stats and ROI for a QR code",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/codes/:id/lifetime",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "codes",
                ":id",
                "lifetime"
              ],
              "query": []
            },
            "description": "Returns lifetime scan statistics for the specified QR code: first scan timestamp, latest scan timestamp, peak day and its scan count, number of days with any scan activity, and total scans. Returns zeroed values when the code has never been scanned.\n\nAlso returns an `roi` block. When the code has a `print_cost_cents` value set, the block includes cost-per-scan, cost-per-print (if `print_count` is set), and a break-even projection. The break-even estimate targets $0.10 cost-per-scan and projects days to reach that threshold at the current scan rate. When no print cost is stored, the `roi` block contains only `print_cost_cents: null` and `print_count`.\n\nRequires a valid bearer JWT. The code must belong to the authenticated user's personal scope or an active team scope. Returns 404 when the code does not exist or is not accessible to the caller.\n"
          },
          "response": []
        },
        {
          "name": "Set print run cost and quantity for ROI tracking",
          "request": {
            "method": "PUT",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v1/codes/:id/lifetime",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "codes",
                ":id",
                "lifetime"
              ],
              "query": []
            },
            "description": "Stores the print run's total cost (in cents) and printed copy count on the code row. These values are used by the GET endpoint to compute cost-per-scan, cost-per-print, and break-even projections without re-prompting the customer.\n\nBoth fields accept null or empty string to clear a previously stored value. Non-null values are coerced to non-negative integers; invalid numeric input is treated as zero.\n\nRequires a valid bearer JWT. The code must belong to the authenticated user's personal scope or an active team scope.\n",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"print_cost_cents\": null,\n  \"print_count\": null\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        },
        {
          "name": "Get public stats sharing state for a code",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/codes/:id/public-stats",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "codes",
                ":id",
                "public-stats"
              ],
              "query": []
            },
            "description": "Returns the current public-stats sharing state for the specified QR code. Indicates whether\na shareable token exists and, if so, provides the share URL.\n\nThe authenticated user must own the code (personal scope: `user_id` match with no team) or\nbe a member of the team that owns the code (team scope). Team role is not restricted for\nreads, any member may check the sharing state.\n\nRequires a valid bearer JWT passed as an Authorization header.\n"
          },
          "response": []
        },
        {
          "name": "Enable public stats sharing for a code",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v1/codes/:id/public-stats",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "codes",
                ":id",
                "public-stats"
              ],
              "query": []
            },
            "description": "Mints a new shareable token that allows anyone with the resulting URL to view analytics for\nthe specified QR code without authenticating. If a token already exists, this operation\nreplaces it.\n\nThe authenticated user must own the code. In team context the caller must hold the `owner`\nrole; non-owners receive a 403. Ownership in personal context requires that the code's\n`user_id` matches the authenticated user and the code has no team.\n\nOn success the action `code.public_stats.enable` is written to the audit log.\n\nRequires a valid bearer JWT.\n",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"data\": null\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        },
        {
          "name": "Revoke all public stats share tokens for a code",
          "request": {
            "method": "DELETE",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/codes/:id/public-stats",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "codes",
                ":id",
                "public-stats"
              ],
              "query": []
            },
            "description": "Revokes every existing shareable token for the specified QR code, disabling public access\nto its analytics. Any previously distributed share URLs stop working immediately.\n\nThe authenticated user must own the code. In team context the caller must hold the `owner`\nrole; non-owners receive a 403. Ownership in personal context requires that the code's\n`user_id` matches the authenticated user and the code has no team.\n\nOn success the action `code.public_stats.disable` is written to the audit log.\n\nRequires a valid bearer JWT.\n"
          },
          "response": []
        }
      ]
    },
    {
      "name": "config",
      "item": [
        {
          "name": "Get the short URL base for the current user or org",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/config/short-url-base",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "config",
                "short-url-base"
              ],
              "query": []
            },
            "description": "Returns the base URL the dashboard should use when displaying or previewing short URLs, for example, the slug-prefix label on the new-code form (\"aqr.net/<slug>\") and the availability hint.\n\nResolution order: if the authenticated user belongs to a white-label org with a verified custom short domain, that domain is returned as the base. Otherwise the environment-configured default (env.SHORT_URL_BASE, falling back to https://aqr.net/) is returned.\n\nAuth is cookie-based, matching the /api/auth/me session model. Every authenticated user can call this endpoint regardless of plan; it is used to render the dashboard correctly.\n"
          },
          "response": []
        }
      ]
    },
    {
      "name": "groups",
      "item": [
        {
          "name": "List groups visible in the current scope",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/groups",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "groups"
              ],
              "query": []
            },
            "description": "Returns all groups accessible to the authenticated user within their current scope. The\nscope is resolved automatically: if the user has an active team context, team-scoped groups\nare returned; otherwise personal groups belonging to the user are returned.\n\nEach group object includes a `code_count` field reflecting the number of QR codes assigned\nto that group. Any authenticated team member may call this endpoint regardless of their role.\nPersonal-scope callers must be the owner of the account.\n\nAuthentication is required. The caller's identity is read from `data.user`, which is\npopulated by an upstream middleware that validates the bearer JWT.\n"
          },
          "response": []
        },
        {
          "name": "Create a new group in the current scope",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v1/groups",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "groups"
              ],
              "query": []
            },
            "description": "Creates a group within the resolved scope of the authenticated user. Scope resolution\nfollows the same logic as the GET operation: team scope is used when the user has an active\nteam, and personal scope otherwise.\n\nIn team scope, the caller must hold the `admin` role or higher; members with lesser roles\nwill receive a 403 response. In personal scope, the user must own the account and the\naccount must be in a writable state (enforced by `requireWritableAccount`, which may reject\nsuspended or over-quota accounts).\n\nOn success the newly created group is returned with HTTP 201 and an audit log entry with\naction `group.create` is written, recording the group id, name, acting user, and team id.\n\nThe request body is parsed as JSON; if the body is missing or malformed it is treated as\nan empty object and default values are applied by the underlying `createGroup` helper.\n\nAuthentication is required via a bearer JWT resolved upstream into `data.user`.\n",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"name\": \"string\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        },
        {
          "name": "Fetch a single group by ID with its code count",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/groups/:id",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "groups",
                ":id"
              ],
              "query": []
            },
            "description": "Retrieves a single group record scoped to the authenticated user's personal or team\ncontext. The scope is resolved from the authenticated user's session, personal users\nsee only their own groups, while team-scoped users see only groups belonging to that\nteam.\n\nIf a group matching the given `id` is found within the resolved scope, the response\nincludes all group fields plus a `code_count` integer reflecting how many QR codes\nare currently assigned to that group.\n\nAuthentication is required via bearer JWT. No role restriction applies for reads ,\nany member of the team (or the personal user) may call this endpoint.\n\nNo mutations or side effects are performed.\n"
          },
          "response": []
        },
        {
          "name": "Rename or update a group's description or color",
          "request": {
            "method": "PATCH",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v1/groups/:id",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "groups",
                ":id"
              ],
              "query": []
            },
            "description": "Partially updates a group identified by `id` within the authenticated user's resolved\nscope. Accepted body fields are `name`, `description`, and `color`; only the fields\npresent in the request body are applied (the underlying `updateGroup` helper performs\na partial update).\n\nAuthentication is required via bearer JWT. For team-scoped requests the caller must\nhold the `admin` role or higher; personal-scope callers must own the group (enforced\nimplicitly by the scoped lookup).\n\nThe account must not be in a read-only / locked state (`requireWritableAccount` is\nenforced). If the account is non-writable the handler returns a structured error with\nthe status and body set by the plan library.\n\nOn success an audit log entry (`group.patch`) is written recording which fields were\nchanged.\n",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"name\": \"string\",\n  \"description\": \"string\",\n  \"color\": \"string\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        },
        {
          "name": "Delete a group and reassign its codes to no group",
          "request": {
            "method": "DELETE",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/groups/:id",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "groups",
                ":id"
              ],
              "query": []
            },
            "description": "Permanently deletes the group identified by `id` within the authenticated user's\nresolved scope. All QR codes previously assigned to this group are reassigned to\n`null` (no group) rather than being deleted themselves.\n\nAuthentication is required via bearer JWT. The account must not be in a read-only /\nlocked state (`requireWritableAccount` is enforced).\n\n**Role and ownership rules:**\n- Personal scope: the group must belong to the authenticated user (enforced by the\n  scoped lookup; no extra ownership check is required).\n- Team scope: the caller must hold the `admin` role or higher. Even among admins,\n  deletion is further restricted to the group's original creator or the team owner.\n  If the caller is an admin but neither the creator nor the team owner, a `403` with\n  `delete_requires_creator_or_owner` is returned.\n\nOn success an audit log entry (`group.delete`) is written recording the group's name.\n"
          },
          "response": []
        },
        {
          "name": "Retrieve aggregated heatmap analytics for a group",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/groups/:id/heatmaps?days=365",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "groups",
                ":id",
                "heatmaps"
              ],
              "query": [
                {
                  "key": "days",
                  "value": "365",
                  "description": "Number of calendar days to include in the daily scan series. Values below 1 are coerced to 365; values above 1 095 are clamped to 1 095. The effective window is also capped by the retention limit of the caller's plan.\n",
                  "disabled": true
                }
              ]
            },
            "description": "Returns scan analytics aggregated across every QR code belonging to the\nspecified group (where codes.group_id = :id). The response includes three\nviews of the data:\n\n**Calendar series**, a zero-filled daily scan count array spanning the\nrequested window (default 365 days, maximum 1 095 days). The window is\nfurther capped by the `ANALYTICS_DAYS` retention limit for the caller's\nplan. Each entry carries a `day` (YYYY-MM-DD) and a `scans` count.\n\n**Hourly day-of-week grid**, a list of `{dow, hour, scans}` rows covering\nthe last 90 days, populated only when the caller's plan includes hourly\nanalytics (`hasHourlyAnalytics`). If the plan does not qualify the array\nis empty and `hourly_available` is `false`.\n\n**Geo breakdown**, top-50 countries by scan volume. Entries with fewer\nthan 5 scans are collapsed into a synthetic `\"Other\"` country entry.\nCountries are ordered by scan count descending.\n\nAccess is governed by `resolveCodeScope` and `getScopedGroup`: the caller\nmust be authenticated (bearer JWT) and must have permission to resolve the\ngroup within their team/personal scope. A 404 is returned if the group\ndoes not exist or is outside the caller's scope. Any unexpected failure\nreturns a generic 500 error object.\n"
          },
          "response": []
        },
        {
          "name": "Get public stats sharing state for a group",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/groups/:id/public-stats",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "groups",
                ":id",
                "public-stats"
              ],
              "query": []
            },
            "description": "Returns the current public-stats sharing state for the specified group,\nincluding whether a shareable token exists and the corresponding share URL\nif one has been minted.\n\nAny authenticated user who can resolve the group within their current scope\n(personal or team) may call this endpoint. No elevated role is required for\nread access.\n\nThe group is resolved via the caller's scope (personal or team), so the\n`{id}` must be visible to the authenticated user. Returns 404 if the group\ncannot be found in the caller's scope.\n"
          },
          "response": []
        },
        {
          "name": "Mint a shareable public stats token for a group",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v1/groups/:id/public-stats",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "groups",
                ":id",
                "public-stats"
              ],
              "query": []
            },
            "description": "Mints a new public-stats share token for the specified group, enabling\nread-only access to the group's aggregated analytics at `/p/<token>/`.\n\nCalling this endpoint while a token already exists will mint a new one\n(the previous token may or may not be invalidated depending on the\nunderlying `mintPublicStatsToken` implementation). A successful mint is\nrecorded in the audit log under the action `group.public_stats.enable`.\n\nAuthorization rules differ by scope:\n- **Team-scoped groups**: only the team owner (role `owner`) may mint a\n  token. Plain team members are denied.\n- **Personal-scoped groups**: only the group's owner (`group.user_id`)\n  may mint a token.\n\nRequires a valid bearer JWT identifying the authenticated user.\n",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"data\": null\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        },
        {
          "name": "Revoke all public stats share tokens for a group",
          "request": {
            "method": "DELETE",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/groups/:id/public-stats",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "groups",
                ":id",
                "public-stats"
              ],
              "query": []
            },
            "description": "Revokes all existing public-stats share tokens for the specified group,\nimmediately invalidating any previously distributed share URLs for the\ngroup's analytics page at `/p/<token>/`.\n\nA successful revocation is recorded in the audit log under the action\n`group.public_stats.disable`.\n\nAuthorization rules mirror those for minting:\n- **Team-scoped groups**: only the team owner (role `owner`) may revoke\n  tokens. Plain team members are denied.\n- **Personal-scoped groups**: only the group's owner (`group.user_id`)\n  may revoke tokens.\n\nRequires a valid bearer JWT identifying the authenticated user.\n"
          },
          "response": []
        },
        {
          "name": "Get rollup statistics for a specific group",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/groups/:id/stats",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "groups",
                ":id",
                "stats"
              ],
              "query": []
            },
            "description": "Returns the same narrative stat tiles produced by the scope-stats and team-stats\nendpoints, codes-by-status breakdown, 30-day and 90-day scan counts, period\ndeltas with sparkline data, Pareto concentration, and linear projection, but\nfiltered to QR codes belonging to the requested group. This endpoint drives the\n/stats/?group=<id> view and the per-group stat tiles on the Groups page.\n\nAccess control is enforced via `getScopedGroup`, which resolves the group within\nthe caller's current scope: team-scoped groups require team membership; personal-\nscoped groups require user ownership. Any caller who can see the group's codes in\nthe list view is permitted to retrieve the rollup, no additional gating applies.\n\nAuthentication is required. The handler reads `data.user` (populated by upstream\nbearer-token middleware) and uses it to resolve the code scope and locate the\ncorrect shard database.\n\nA per-group stats cache is consulted before executing the 20+ underlying queries.\nOn a cache hit the cached blob is returned immediately with `cache.hit: true`. On\na miss, stats, a peer-group benchmark, and the group's code count are fetched\nconcurrently; the result is written back to the cache asynchronously via\n`waitUntil` (or a detached promise) before the response is returned.\n\nNo mutation of user or group data occurs. The only side effect is a cache write\nthat may complete after the response is sent.\n"
          },
          "response": []
        }
      ]
    },
    {
      "name": "health",
      "item": [
        {
          "name": "Liveness probe reporting runtime, sweeper, and backup health",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/health",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "health"
              ],
              "query": []
            },
            "description": "Public liveness probe intended for external uptime monitors and the customer-facing\nstatus page. Requires no authentication. Returns a JSON object summarising three\nlayers of health:\n\n1. **Bindings**, verifies that the D1 database binding (`DB`) and KV namespace\n   binding (`KV`) are present on the Worker runtime. This check is purely in-process\n   and has no external dependencies.\n\n2. **Sweeper heartbeat**, performs a single KV `get` for the key\n   `sweeper:last_heartbeat`, written by the scheduled sweeper cron on every\n   successful run. Reports the timestamp of the last run, elapsed age in seconds and\n   hours, and the error count from that run. Considered stale when the heartbeat is\n   older than 48 hours or the last run recorded one or more errors.\n\n3. **Nightly backup summary**, executes one `SELECT \u2026 GROUP BY \u2026 LIMIT 1` query\n   against the `backups` D1 table (introduced in migration 021) to surface the most\n   recent backup's timestamp, table count, and ciphertext byte total. Considered\n   stale when the most recent backup is older than 30 hours. If the `backups` table\n   does not yet exist (pre-migration-021 database) the backup fields are returned as\n   `null` and the top-level `ok` is not degraded.\n\nThe top-level `ok` field is `false` when any of the following are true: bindings are\nmissing, `sweeper.ok` is explicitly `false`, or `backup.ok` is explicitly `false`. A\n`null` value for either subsystem (no data recorded yet) is treated as neutral and\ndoes not cause `ok` to become `false`, so a first-deploy before any cron run does\nnot trigger an alert.\n\nResponses are served with `Cache-Control: public, max-age=60` so that monitors\npolling at five-minute intervals do not generate excessive KV or D1 traffic.\n",
            "auth": {
              "type": "noauth"
            }
          },
          "response": []
        }
      ]
    },
    {
      "name": "invites",
      "item": [
        {
          "name": "Create an invite and promote solo workspace to team if needed",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v1/invites",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "invites"
              ],
              "query": []
            },
            "description": "Creates a new team invite for the given email address and role. This is the\n\"bare-path\" invite endpoint, intended for use before the caller knows whether\nthe authenticated owner already has a real team. If the owner has no existing\nteam, this call transparently promotes their solo virtual workspace into a\nfull team (\"promotion on first invite\") before issuing the invite. Subsequent\ncalls are idempotent with respect to team creation, the same endpoint\ncontinues to work once a team already exists.\n\nAfter the invite record is created, the endpoint attempts to send an invite\nemail to the specified address. Email delivery failure is logged but does not\ncause the request to fail; the invite token remains valid regardless.\n\nThe response includes the created invite object and a team summary. The\n`team.promoted` flag is `true` when this specific call triggered the\nworkspace-to-team promotion, allowing the UI to display a confirmation\nmessage. The flag is `false` when the owner's team already existed before\nthis call.\n\nAuthentication is required. The authenticated user must be on a Team or\nAgency plan; users on lower-tier plans receive a 403. The handler reads the\ncurrent user from `data.user`, which is populated upstream by session/JWT\nmiddleware and must be present.\n",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"email\": \"user@example.com\",\n  \"role\": \"admin\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        },
        {
          "name": "Look up an invite by token",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/invites/:token",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "invites",
                ":token"
              ],
              "query": []
            },
            "description": "Retrieves the metadata for a pending invite identified by its token. This is\nused by the invite-acceptance UI to display the team name, inviter email,\ninvited email address, role, and expiry before the user confirms.\n\nThe endpoint is authentication-required: the caller must supply a valid bearer\nJWT. In addition to the invite fields, the response includes the currently\nsigned-in user's email and a boolean indicating whether it matches the address\nthe invite was sent to. This allows the front-end to warn the user if they are\nabout to accept an invite on behalf of the wrong account.\n\nThe `inviter_id` field is intentionally withheld from the response; only the\ninviter's email address is exposed, consistent with what the recipient already\nsaw in the invitation email.\n\nReturns 404 when the token is not found or has expired.\n"
          },
          "response": []
        },
        {
          "name": "Accept a pending team invite",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v1/invites/:token",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "invites",
                ":token"
              ],
              "query": []
            },
            "description": "Accepts the invite identified by the token on behalf of the currently\nauthenticated user. The handler calls `acceptInvite`, which validates that\nthe token is still valid and that the signed-in user's email matches the\naddress the invite was issued to; if either check fails the library throws\na structured error that is forwarded to the caller as the appropriate HTTP\nstatus.\n\nOn success two webhook events are fired asynchronously:\n\n- `invite.accepted`, carries `team_id`, `user_id`, `email`, and `role`.\n- `team.member_added`, carries the same fields plus `via: \"invite\"`.\n\nBoth events are scoped to the team and the accepting user. If the runtime\nsupports `waitUntil`, delivery is deferred; otherwise the promises are\nfire-and-forget.\n\nA transactional push notification is also sent to the inviter (if they are a\ndifferent user from the acceptor) informing them that their invitation was\naccepted. Transactional pushes bypass topic opt-in but still respect master\nopt-out and Do Not Disturb settings.\n\nThe endpoint is authentication-required: a valid bearer JWT must be present\nso the handler can identify which user is accepting the invite.\n\nNo request body is needed; all required information is derived from the token\npath parameter and the authenticated user's session.\n",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"data\": null\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        },
        {
          "name": "Fetch public invite details by token",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/invites/:token/public",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "invites",
                ":token",
                "public"
              ],
              "query": []
            },
            "description": "Returns the minimal, unauthenticated details needed to render the invite\nlanding page before a user signs in or creates an account.\n\nAuthentication is not required. The 256-bit random token embedded in the\nURL serves as the access control mechanism, possession of the token is\ntreated as proof that the caller is the intended recipient, since the token\nis delivered exclusively via the invitation email.\n\nOnly non-sensitive fields are exposed: the team name, the role being\noffered, the invited email address (already known to the recipient from\nthe email they received), and the expiry timestamp. Internal identifiers\nsuch as the inviter's user ID and the team's internal ID are never\nreturned.\n\nNo side effects are produced by this endpoint. It is a pure read\noperation. If the token does not exist or has expired, a 404 is returned\nwith a stable error code that the client can use to display an appropriate\nmessage.\n"
          },
          "response": []
        },
        {
          "name": "Initiate account creation or login bridge for a team invite",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v1/invites/:token/register-bridge",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "invites",
                ":token",
                "register-bridge"
              ],
              "query": []
            },
            "description": "Unauthenticated endpoint called by the invite accept page when a visitor\nchooses \"Create an account.\" Given a valid 256-bit team invite token, this\nhandler looks up the pending invitation and forwards the invitee's email\naddress, team name, and a post-auth return URL to the abundera.ai\n`/auth/team-invite-bridge` service.\n\nThe bridge service determines whether the invitee's email already has an\nAbundera account. If so, it returns a `login_url`; otherwise it mints a\nfresh auth invite and returns a `register_url` pre-filled with the\ninvitee's email. The response is passed straight through to the caller,\nwhich redirects the user accordingly. After authentication completes, the\nuser is returned to the invite accept page via the `return_url` query\nparameter to complete team onboarding as normal.\n\nSecurity model: the 256-bit invite token serves as the access control\ncredential. Only data already present in the invitation email (email\naddress and team name) is forwarded to the bridge. No user session or\nbearer token is required. The handler additionally requires the\n`ABUNDERA_SERVICE_SECRET` environment variable to be configured; if it is\nabsent the endpoint returns 503 rather than falling through to an insecure\npath. The upstream bridge call is authenticated via an `X-Service-Secret`\nheader using that secret.\n\nThis endpoint is rate-limited by the platform middleware. The Origin header\ncheck is exempted because this is a cross-auth unauthenticated POST where\nthe invite token is the secret.\n",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"data\": null\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        }
      ]
    },
    {
      "name": "keepalive",
      "item": [
        {
          "name": "Renew a QR route keep-alive via signed Ed25519 payload",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v1/keepalive/renew",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "keepalive",
                "renew"
              ],
              "query": []
            },
            "description": "Accepts a signed keep-alive renewal payload for a QR route identifier and\ninserts it into the epoch-keyed Bloom filter stored in Cloudflare KV. This\nimplements the server-side renewal verification step described in Patent\nQR-13, independent claim 1.\n\nThe handler verifies an Ed25519 signature over the concatenated string\n`${route_id}|${epoch}|${timestamp}` using the raw public key supplied\ninline in the request body. The timestamp must be within 300 seconds of\nthe server clock; requests outside this window are rejected with\n`timestamp_skew`. If the signature does not verify, the request is\nrejected with `invalid_signature`.\n\nOn successful verification the route identifier is added to the in-memory\nBloom filter for the given epoch. The filter is flushed to KV either when\n1 000 renewals have accumulated in the current Worker isolate or when at\nleast 60 seconds have elapsed since the last flush, whichever comes first.\nThis batched-flush strategy is an intentional performance optimisation;\nup to 1 000 pending renewals may be lost if the Worker isolate is evicted\nbefore a flush occurs, but clients are expected to re-fire renewals\nperiodically so no durable data loss results.\n\nThe baseline TTL of the routing record is never modified by this endpoint.\nPublic-key anchoring to the routing record (Patent QR-13 \u00a76.4 step b) is\nnot yet enforced; the public key is accepted inline and is used only for\nsignature verification.\n\nNo authentication beyond the Ed25519 signature is required. There are no\ndocumented rate limits enforced by this handler.\n",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"route_id\": \"string\",\n  \"epoch\": \"string\",\n  \"timestamp\": 0,\n  \"signature\": \"string\",\n  \"public_key\": \"string\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        },
        {
          "name": "Revoke a keepalive route from the Cuckoo-filter epoch state",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v1/keepalive/revoke",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "keepalive",
                "revoke"
              ],
              "query": []
            },
            "description": "Explicitly revokes a previously registered route from the Cuckoo-filter aggregate\nstate for a given epoch, immediately disabling resolution for that route. This\nimplements Patent QR-13 \u00a76.8 explicit revocation and acts as the counterpart to\nthe /api/keepalive/renew endpoint.\n\nThe caller must supply a valid Ed25519 signature over the canonical message\n`revoke|${route_id}|${epoch}|${timestamp}` using the raw 32-byte Ed25519 public\nkey also provided in the request body. The \"revoke|\" prefix ensures that a\nrenewal signature cannot be replayed as a revocation signature and vice versa.\n\nThe timestamp field must be a Unix epoch value in seconds and must fall within\n300 seconds of the server's current time. Requests outside this skew window are\nrejected with a `timestamp_skew` error to prevent replay attacks.\n\nNo bearer token or session cookie is required; authentication is achieved\nentirely through the Ed25519 signature verification. Upon successful verification\nthe route is removed from the in-memory Cuckoo filter for the specified epoch\nand the updated filter is persisted back to KV storage. The `removed` field in\nthe response indicates whether the route was actually found and deleted from the\nfilter.\n",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"route_id\": \"string\",\n  \"epoch\": \"string\",\n  \"timestamp\": 0,\n  \"signature\": \"string\",\n  \"public_key\": \"string\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        }
      ]
    },
    {
      "name": "keybound",
      "item": [
        {
          "name": "Register a TEE-bound public key routing record",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v1/keybound/register",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "keybound",
                "register"
              ],
              "query": []
            },
            "description": "Creates a new keybound routing record that associates a caller-supplied\npublic key with an HTTPS destination URL. The record is written to D1 as\nthe system of record and simultaneously mirrored to KV so that the\nscan-time QR redirect worker can resolve it on the hot path without\ntouching D1.\n\nAuthorization model: there is no session, no user identity, and no\npassword. The supplied public key itself is the sole authorization\nprincipal (Patent QR-11 claim 1). Any caller that possesses a valid\nkey pair may register a route; subsequent mutation endpoints are\nexpected to verify possession of the corresponding private key.\n\nNo explicit rate limiting is enforced in this handler. The only\nserver-side validation performed is that `public_key` is a string of\nat least 40 characters and that `destination` is a string beginning\nwith `https://`. All other structural or cryptographic validation of\nthe public key is deferred to later operations.\n\nSide effects: one row is inserted into the `keybound_routes` D1 table\nand one key of the form `kb:{routing_id}` is written to KV with an\ninitial `sequence_hwm` of 0 and a status of `active`.\n",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"public_key\": \"string\",\n  \"destination\": \"string\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        },
        {
          "name": "Update a keybound route's destination via Ed25519 signature",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v1/keybound/update",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "keybound",
                "update"
              ],
              "query": []
            },
            "description": "Modifies the destination URL of an existing keybound route. This endpoint implements\nPatent QR-11 claim 2 and enforces a strict cryptographic authorization model: the\nonly accepted proof of authority is a valid Ed25519 signature over the concatenated\nstring `${routing_id}|${new_destination}|${sequence}`, verified against the public\nkey bound to the routing record at creation time.\n\nThere is no admin override, no session-based access, and no recovery path. If the\nbound private key is lost, the route cannot be updated.\n\nThe `sequence` field must be strictly greater than the stored `sequence_hwm` (high-water\nmark) for the routing record, providing replay protection. The `timestamp` field must\nbe within a 300-second skew window of the server's current time.\n\nOn success, the handler updates the `keybound_routes` database row (setting\n`destination`, `sequence_hwm`, and `updated_at`) and mirrors the new state to KV\nunder the key `kb:{routing_id}`, preserving any previously stored `created_at`,\n`bound_pubkey_b64`, and `status` fields from the existing KV entry.\n\nNo rate limiting or quota enforcement is visible in this handler.\n",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"routing_id\": \"string\",\n  \"new_destination\": \"string\",\n  \"sequence\": 0,\n  \"timestamp\": 0,\n  \"signature\": \"string\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        }
      ]
    },
    {
      "name": "org",
      "item": [
        {
          "name": "Read the authenticated caller's org configuration",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/org",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "org"
              ],
              "query": []
            },
            "description": "Returns the organisation record associated with the currently authenticated user.\nThe record is resolved from an upstream federated source (abundera.ai's\n/auth/service/orgs/{id}/branding); Pro QR holds no local organisations table.\n\nAuthentication is required. The handler reads `data.org` and `data.user`, which\nare populated by upstream middleware that validates the bearer JWT. If the\nauthenticated user is not associated with an Enterprise organisation, the\nendpoint returns 404.\n\nDomain-level fields such as `short_url_domain` and `dashboard_domain` are\nadmin-only and are not included in this response; use /api/admin/orgs for those.\n\nNo side effects. No mutations are performed by this method.\n"
          },
          "response": []
        },
        {
          "name": "Update self-serve branding fields for the caller's org",
          "request": {
            "method": "PATCH",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v1/org",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "org"
              ],
              "query": []
            },
            "description": "Partially updates the caller's organisation's branding configuration. Only the\norganisation owner (the user whose `id` matches `org.owner_user_id`) may call\nthis method; any other authenticated member receives 403.\n\nThis endpoint is a federated pass-through: updates are forwarded to\nabundera.ai's /auth/service/orgs/{id}/branding via an internal service client\nauthenticated with `ABUNDERA_SERVICE_SECRET`. Pro QR holds no local\norganisations table; canonical state lives upstream.\n\nAccepted fields are limited to the self-serve allow-list: `display_name`,\n`primary_color`, `accent_color`, `support_email`, `email_from_name`,\n`logo_url`, and `footer_text`. Legacy field aliases `name` (maps to\n`display_name`) and `brand_color` (maps to `primary_color`) are also accepted\nfor backwards compatibility with existing dashboard clients.\n\nDomain fields (`short_url_domain`, `dashboard_domain`) are admin-only and\ncannot be updated via this endpoint.\n\nOn success the upstream branding record is updated, the local org cache is\ninvalidated, the updated org is re-resolved from upstream, and the flattened\nlegacy-shape org object is returned so that existing dashboard render code\ncontinues to work without a UI redeploy.\n\nReturns 400 if no patchable fields are provided, if a color value is not a\nvalid hex string, if `logo_url` is present but not a valid HTTPS URL, or if\n`support_email` is present but does not contain an `@` character.\n\nReturns 502 if the upstream branding update call fails.\n",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"display_name\": \"string\",\n  \"name\": \"string\",\n  \"primary_color\": \"string\",\n  \"brand_color\": \"string\",\n  \"accent_color\": \"string\",\n  \"support_email\": \"string\",\n  \"email_from_name\": \"string\",\n  \"logo_url\": \"string\",\n  \"footer_text\": \"string\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        },
        {
          "name": "Fetch minimal brand config for an org",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/org/brand?id=string",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "org",
                "brand"
              ],
              "query": [
                {
                  "key": "id",
                  "value": "string",
                  "description": "The organisation ID to look up (e.g. `org_xxx`). Optional when the request arrives on a white-label custom domain whose org has already been resolved by the middleware. If both are present this parameter takes precedence.\n",
                  "disabled": true
                }
              ]
            },
            "description": "Returns a minimal set of branding fields, display name, accent colour, and\nlogo URL, for the specified organisation. This data is consumed by the\nabundera.ai login page when a user is redirected from a white-label dashboard\ndomain (e.g. app.acmecorp.com \u2192 abundera.ai/login?brand=org_xxx) so that the\nlogin UI can render with the correct brand identity.\n\nThe org to resolve is determined in one of two ways. If the request arrives on\na custom domain, the middleware has already resolved `data.org` and no query\nparameter is needed. If an explicit `id` query parameter is supplied it takes\nprecedence and the org is resolved fresh from the database.\n\nThis is a fully public endpoint, no authentication or session cookie is\nrequired. It is allowlisted in `_middleware.js`.\n\nOnly organisations that exist in the database are returned. Orgs that are not\nfound yield a 404. The response body is intentionally minimal: no internal\nconfiguration, no domain details, and no sensitive fields are exposed.\n\nResponses are cacheable for 60 seconds (`Cache-Control: public, max-age=60`)\nto reduce database load during bursts of auth redirects, while still allowing\nbranding changes to propagate quickly.\n"
          },
          "response": []
        },
        {
          "name": "Register or verify a custom hostname for the caller's org",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v1/org/verify-domains",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "org",
                "verify-domains"
              ],
              "query": []
            },
            "description": "Initiates or advances verification of a custom hostname associated with the\nauthenticated user's organisation. The caller must be the org owner; any\nother authenticated user receives 403.\n\nThe `type` field selects which hostname to operate on: `short_url` maps to\nthe org's `short_url_domain` field (CNAME target `aqr.net`) and `dashboard`\nmaps to `dashboard_domain` (CNAME target `pro-qr-abundera-ai.pages.dev`).\nThe relevant hostname must already be set on the org via a prior PATCH to\n`/api/admin/orgs`; if it is absent the request fails with 400.\n\nInternally this endpoint federates to the abundera.ai\n`/auth/service/orgs/{id}/hostnames` API. If a hostname row for the\nrequested purpose already exists the call drives verification (polling\nCloudflare for DCV and certificate provisioning); otherwise it registers the\nhostname first, which also kicks off provisioning. The operation is\nidempotent: repeated POST calls are safe and advance the state machine.\n\nOn success the org cache is invalidated so subsequent reads reflect the\nupdated status. A 502 is returned if the federated registration or\nverification call fails to return a row.\n\nAuthentication is required via bearer JWT. Only the org owner may call\nthis endpoint.\n",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"type\": \"short_url\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        },
        {
          "name": "Read current custom-domain verification state for the caller's org",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/org/verify-domains",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "org",
                "verify-domains"
              ],
              "query": []
            },
            "description": "Returns the current verification state for both custom domain types\n(`short_url_domain` and `dashboard_domain`) associated with the\nauthenticated user's organisation.\n\nThe response is assembled entirely from the flattened federated payload that\nmiddleware has already attached to the request context (`data.org`); no\nadditional RPC or database call is made by this handler.\n\nFor each domain type the response includes the configured hostname (or null\nif not set), a boolean verified flag, and, when a hostname is configured ,\nthe required CNAME record the customer must create (name and target).\n\nAuthentication is required via bearer JWT.\n"
          },
          "response": []
        }
      ]
    },
    {
      "name": "org-logo",
      "item": [
        {
          "name": "Serve an org logo from R2 storage",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/org/logo?key=string",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "org",
                "logo"
              ],
              "query": [
                {
                  "key": "key",
                  "value": "string",
                  "description": "The R2 object key of the logo to retrieve. Must begin with `org-logos/`. Typically of the form `org-logos/<org_id>/logo.<ext>` as written by the PUT operation.\n",
                  "disabled": false
                }
              ]
            },
            "description": "Retrieves a previously uploaded organisation logo from R2 object storage and streams it\ndirectly to the caller. This endpoint is intentionally unauthenticated so that logos can\nbe embedded in public-facing email templates and other externally visible surfaces.\n\nThe caller must supply the `key` query parameter, which must begin with the prefix\n`org-logos/`. Any request whose key is absent or does not start with that prefix receives\na 404 response. Likewise, if the key is valid but no object exists in the bucket under\nthat key, a 404 is returned.\n\nSuccessful responses are served with a `Cache-Control: public, max-age=86400` header\n(24-hour caching) and `Access-Control-Allow-Origin: *` to permit cross-origin use. The\n`Content-Type` is taken from the object's stored HTTP metadata, falling back to\n`image/png` if none is recorded.\n\nNo rate limiting or authentication is enforced by this handler.\n"
          },
          "response": []
        },
        {
          "name": "Upload a new org logo to R2 storage",
          "request": {
            "method": "PUT",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v1/org/logo",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "org",
                "logo"
              ],
              "query": []
            },
            "description": "Accepts a base64-encoded image and stores it in R2 as the canonical logo for the\nauthenticated user's organisation. Only the organisation owner may call this endpoint;\nany authenticated user whose `user.id` does not match `org.owner_user_id` receives a\n403 response.\n\nThe handler reads a JSON body containing the MIME type and the raw image bytes encoded\nas a standard base64 string. Supported MIME types are `image/png`, `image/jpeg`,\n`image/svg+xml`, and `image/webp`. The decoded payload must not exceed 1 MB (1,048,576\nbytes); larger uploads are rejected with 413.\n\nThe logo is stored under the R2 key `org-logos/<org_id>/logo.<ext>`, where the\nextension is derived from the MIME type. Because the key is deterministic, uploading a\nnew logo silently overwrites the previous one, there is no versioning.\n\nAfter a successful R2 write the handler persists the resulting public URL to the\nfederated branding record via an internal `updateBranding` call and invalidates the\nin-memory org cache. If the branding update fails a 502 is returned, though the object\nwill already have been written to R2.\n\nAuthentication is required. The handler expects `data.org` and `data.user` to have been\npopulated by upstream middleware (bearer-token-based auth). If `data.org` is absent the\nrequest is treated as 404. R2 storage must be configured in the environment; its absence\nyields 503.\n",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"filename\": \"string\",\n  \"mime_type\": \"image/png\",\n  \"data\": \"string\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        }
      ]
    },
    {
      "name": "otp",
      "item": [
        {
          "name": "Verify hardware attestation and deliver TOTP seed",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v1/otp/provision-attest",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "otp",
                "provision-attest"
              ],
              "query": []
            },
            "description": "Implements Patent QR-08 claim 1 steps (e)\u2013(g). Accepts a signed hardware\nattestation envelope from the client device, performs chained attestation\nverification via `verifyAttestation`, and, only upon successful completion\nof all verification checks, returns the cryptographic TOTP seed that was\nheld in escrow under the given provisioning ID.\n\nThe nonce associated with the provisioning ID is consumed (deleted from KV)\nimmediately after successful seed delivery, per claim 1 step (h). This\nsingle-use guarantee ensures that each provisioning event requires a fresh\nserver-issued nonce; replaying a previously used provisioning ID returns\n`nonce_not_found_or_consumed`.\n\nIf attestation verification succeeds and the result includes a FIDO2/WebAuthn\ncredential, the credential is persisted to the `webauthn_credentials` D1\ntable so that subsequent assertion operations (rotation, recovery) can verify\nagainst the stored authenticator without re-running the registration ceremony.\nCredential persistence failure is non-fatal: seed delivery still proceeds, but\nre-attestation would be required for any rotation operation.\n\nNo bearer token or session cookie is required by this handler. The security\nmodel relies entirely on possession of a valid server-issued nonce (retrieved\nfrom KV under `otp:nonce:{provisioning_id}`) and the cryptographic integrity\nof the attestation envelope signature. Any verification failure results in a\n401 with a descriptive error token and no seed material is disclosed.\n\nNo explicit rate limiting is implemented in this handler beyond the nonce\nsingle-use constraint.\n",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"provisioning_id\": \"string\",\n  \"envelope\": {\n    \"nonce\": \"string\",\n    \"device_integrity\": {\n      \"platform\": \"string\",\n      \"attestation_format\": \"string\",\n      \"integrity_claims\": {}\n    },\n    \"attestation_pubkey\": \"string\",\n    \"issued_at\": \"string\",\n    \"signature\": \"string\"\n  }\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        },
        {
          "name": "Initialise a TOTP provisioning session and return a nonce-bearing QR payload",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v1/otp/provision-init",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "otp",
                "provision-init"
              ],
              "query": []
            },
            "description": "Begins a new TOTP provisioning flow (Patent QR-08, claim 1, steps a and b).\n\nA cryptographically random provisioning ID and nonce are generated server-side.\nA fresh TOTP seed is also generated but is never returned to the caller; it is\nstored transiently in Cloudflare KV under the key `otp:nonce:<provisioning_id>`\nalongside the nonce and a creation timestamp.  The KV record expires automatically\nafter `expires_in` seconds (defined by the server constant `NONCE_TTL_SECONDS`).\n\nThe response includes a `qr_payload` string that the client is expected to encode\ninto a QR code and present to the end-user's authenticator device.  The payload\nembeds the provisioning ID and nonce in a URL fragment; it carries no seed\nmaterial whatsoever, satisfying claim 1 step (b) of the referenced patent.\n\nThe authenticator device must subsequently call the `validation_endpoint`\n(`/api/otp/provision-attest`) to complete attestation and retrieve the seed.\n\nNo authentication is required to call this endpoint, it is an unauthenticated\ninitiation step.  No rate-limit logic is present in the handler itself, though\nplatform-level or upstream rate limiting may apply.  Each call unconditionally\ncreates a new KV record; callers should avoid polling this endpoint excessively\nto prevent KV write exhaustion.\n",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"data\": null\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        }
      ]
    },
    {
      "name": "p",
      "item": [
        {
          "name": "Serve the public shared-stats shell HTML page",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/p/:token",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "p",
                ":token"
              ],
              "query": []
            },
            "description": "Returns a self-contained HTML shell page for a public share link identified by the\npath token. The page itself contains no scan data; it is a minimal client-side shell\nthat reads the token from `location.pathname` and issues a subsequent browser-side\n`fetch` to `/api/p/<token>/stats` to load live statistics.\n\nNo authentication is required. The endpoint is intentionally public so that\nrecipients of a share link can view aggregate scan statistics without logging in.\nThe owner of the share can revoke access at any time; revocation is enforced by\nthe downstream `/api/p/<token>/stats` endpoint (which returns 410), not here.\n\nThe response is always HTTP 200 regardless of whether the token is valid. Token\nvalidity is determined client-side after the stats API responds. The HTML is\ninlined in the function rather than fetched from the origin to avoid same-origin\nfetch loops on Cloudflare Pages.\n\nNo cookies, bearer tokens, or service secrets are read. No side effects are\nproduced. The response carries `Cache-Control: no-store` to prevent proxies and\nbrowsers from caching the shell, and `X-Content-Type-Options: nosniff` as a\ndefence-in-depth header.\n",
            "auth": {
              "type": "noauth"
            }
          },
          "response": []
        }
      ]
    },
    {
      "name": "p-token-stats",
      "item": [
        {
          "name": "Retrieve public shared analytics for a code, team, or group",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/p/:token/stats",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "p",
                ":token",
                "stats"
              ],
              "query": []
            },
            "description": "Returns aggregated, anonymised scan analytics for a QR code, team, or code\ngroup that has been shared via a public-stats token. The caller supplies the\nraw opaque token (minted by the corresponding public-stats POST endpoint);\nthe server SHA-256-hashes it internally and looks up the matching\n`public_stats_tokens` row to determine scope and ownership.\n\nNo authentication is required, this endpoint is intentionally public so\nthat share links can be opened by anyone with the URL. No personally\nidentifiable information, destination URLs, or raw shortcodes are ever\nincluded in the response.\n\nThe response shape varies by scope:\n- **code**, stats and heatmaps for a single QR code.\n- **team**, rolled-up stats and heatmaps across all codes in the team,\n  plus a code-count summary.\n- **group**, same roll-up as team but scoped to a code group, with an\n  additional `group` object carrying the group's colour tag.\n\nGeographic data is subject to a noise floor: any country with fewer than\nfive scans is collapsed into an `\"Other\"` bucket to prevent single-scan\ncountry leakage. Calendar heatmap data covers the trailing 365 days,\nzero-filled for days with no scans.\n\nA revoked token returns **410 Gone** so the consuming page can distinguish\na deliberate revocation from a generic missing-token 404.\n\nNo side effects. No rate-limit is enforced at the handler level.\n"
          },
          "response": []
        }
      ]
    },
    {
      "name": "push",
      "item": [
        {
          "name": "Save or update a Web Push subscription for the authenticated user",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v1/push/subscribe",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "push",
                "subscribe"
              ],
              "query": []
            },
            "description": "Persists a Web Push subscription (endpoint + VAPID keys) for the currently\nauthenticated user. The operation is idempotent: if a row already exists for\nthe same (user_id, endpoint) pair, the p256dh and auth keys are overwritten\nand created_at is refreshed, rather than inserting a duplicate. This allows\na browser that has had push permission revoked and re-granted to re-register\ncleanly.\n\nA single user may hold subscriptions from multiple browsers or devices\nsimultaneously; each subscription receives push deliveries independently.\nExpired or invalid subscriptions (HTTP 404/410 from the push service) are\ncleaned up automatically by the delivery layer and do not need to be removed\nmanually.\n\nRequires a valid bearer JWT. The authenticated user identity is sourced from\n`data.user` populated by upstream middleware.\n",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"endpoint\": \"string\",\n  \"keys\": {\n    \"p256dh\": \"string\",\n    \"auth\": \"string\"\n  }\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        },
        {
          "name": "Remove a Web Push subscription for the authenticated user",
          "request": {
            "method": "DELETE",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v1/push/subscribe",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "push",
                "subscribe"
              ],
              "query": []
            },
            "description": "Deletes the push subscription row identified by the given endpoint for the\ncurrently authenticated user. Only the row belonging to the calling user is\naffected; other users' subscriptions for the same endpoint (if any) are\nuntouched.\n\nAfter a successful deletion an audit log entry is recorded with action\n`push.unsubscribe` and target type `push_subscription`, capturing the\nactor's user ID and email.\n\nIf the endpoint does not exist in the database the DELETE is a no-op and\nthe response is still `{ ok: true }`, because the desired state (no active\nsubscription for that endpoint) is already satisfied.\n\nRequires a valid bearer JWT. The authenticated user identity is sourced from\n`data.user` populated by upstream middleware.\n",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"endpoint\": \"string\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        },
        {
          "name": "Send a test push notification to all subscribed browsers",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v1/push/test",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "push",
                "test"
              ],
              "query": []
            },
            "description": "Fires a one-off \"Test notification, it works!\" push message to every browser\nsubscription associated with the authenticated user. The notification is delivered\nwith a fixed title (\"Abundera QR Pro\"), body, URL (/account/), and tag (\"test-push\").\n\nUnlike production push dispatches, this endpoint bypasses all topic filtering,\nDo Not Disturb gating, and quiet-hours logic. Its sole purpose is to let a user\nverify end-to-end push delivery without needing to configure or wait for real\nnotification triggers.\n\nAuthentication is via session cookie only, the handler reads the user identity\nfrom `data.user.id` populated by cookie-auth middleware. Unauthenticated requests\nwill be rejected before reaching this handler.\n\nRate limiting is governed by the standard per-user middleware bucket shared across\nthe API. No additional quota is imposed by this endpoint; realistically a user\nwould only invoke it once or twice per minute during setup verification.\n\nSide effects: for each subscription endpoint that returns a permanent failure\n(e.g. HTTP 410 Gone from the push service), the subscription record is deleted\nfrom the database (reflected in the `cleaned` count).\n",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"data\": null\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        },
        {
          "name": "Retrieve the VAPID public key for push subscription setup",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/push/vapid-key",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "push",
                "vapid-key"
              ],
              "query": []
            },
            "description": "Returns this product's VAPID (Voluntary Application Server Identification) public key,\nwhich browsers need to call `PushManager.subscribe({ applicationServerKey })`. The public\nkey is one half of an asymmetric keypair and is safe to expose publicly by design; the\ncorresponding private key remains confidential in the server environment.\n\nServing the key from a dedicated endpoint rather than baking it into the JavaScript bundle\nat build time allows the key to be rotated by updating a worker secret alone, without\nrequiring a full redeployment of every page that depends on it.\n\nNo authentication is required. The key is identical for every caller and there are no\nper-user or per-session considerations.\n\nResponses are marked `public, max-age=300`, allowing shared caches (CDN edges, browser\ncaches) to serve the value for up to five minutes. This limits the delay between a key\nrotation and clients observing the new key to at most five minutes.\n\nIf the `VAPID_PUBLIC_KEY` environment variable has not been configured in the deployment,\nthe endpoint still returns HTTP 200 with `key` set to `null`, allowing subscriber UI code\nto detect and surface a \"Push not set up yet\" message rather than encountering an\nunhandled error.\n"
          },
          "response": []
        }
      ]
    },
    {
      "name": "push-prefs",
      "item": [
        {
          "name": "Retrieve product-local and federated push preferences",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/push/prefs",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "push",
                "prefs"
              ],
              "query": []
            },
            "description": "Returns the authenticated user's push notification preferences for pro.qr, combining\ntwo data sources in a single response.\n\n**Local preferences** (stored in this product's D1 database) contain only the\nproduct-scoped master push toggle (`push_enabled`). All other mute/quiet-hours\nstate is owned by abundera.ai.\n\n**Federated preferences** are fetched cross-origin from\n`https://abundera.ai/auth/service/push-prefs` on every request, using an internal\nservice secret (`X-Service-Secret`). The upstream call is subject to a 2 500 ms\ntimeout. If the upstream is unavailable or returns a non-2xx status the federated\nfields (`dnd_until`, `quiet_hours_start`, `quiet_hours_end`, `tz`, `muted`,\n`muted_reason`) will be `null`/`false` and `federation_ok` will be `false`,\nallowing the UI to display a graceful degradation message rather than a stale or\nincorrect value.\n\nAuthentication is required; the handler reads the caller's identity from\n`data.user` which is populated by upstream middleware that validates a bearer JWT.\nNo explicit rate limit is enforced at this handler layer.\n"
          },
          "response": []
        },
        {
          "name": "Update the product-local push notification master toggle",
          "request": {
            "method": "PATCH",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v1/push/prefs",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "push",
                "prefs"
              ],
              "query": []
            },
            "description": "Updates the authenticated user's product-scoped push notification master toggle for\npro.qr. Only `push_enabled` may be set via this endpoint; DND, quiet hours, and\ntimezone are user-level settings managed exclusively through abundera.ai\n(`PATCH https://abundera.ai/auth/service/push-prefs`).\n\nThe handler performs an upsert into `notification_prefs`: if a row for the user does\nnot yet exist it is created with `weekly_digest` and `anomaly_alerts` defaulting to\n`0`. If a row already exists only `push_enabled` and `updated_at` are modified;\nexisting digest and alert values are preserved by the `ON CONFLICT \u2026 DO UPDATE`\nclause.\n\nAfter writing, the handler re-reads both the local row and the federated preferences\nfrom abundera.ai (same cross-origin call as the GET, with the same 2 500 ms timeout\nand graceful-degradation behaviour) and returns the full merged preference object so\nthe caller does not need a subsequent GET.\n\nAuthentication is required; the handler reads the caller's identity from `data.user`\nwhich is populated by upstream middleware that validates a bearer JWT. No explicit\nrate limit is enforced at this handler layer.\n",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"push_enabled\": true\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        }
      ]
    },
    {
      "name": "push-subscriptions",
      "item": [
        {
          "name": "List push subscriptions for the authenticated user",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/push/subscriptions",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "push",
                "subscriptions"
              ],
              "query": []
            },
            "description": "Returns all browser push subscriptions currently registered for the authenticated user,\nordered by registration date descending. Each row includes an opaque short identifier\n(the first 10 hex characters of the SHA-256 of the push endpoint URL), a human-friendly\nbrowser family label derived from the push service host (e.g. \"Chrome / Edge / Opera\",\n\"Firefox\", \"Safari\"), and the creation timestamp.\n\nThe endpoint URL itself is never returned; it is treated as a bearer-ish credential and\nkept server-side only. The short `id` is deterministic, so the UI can reference a specific\ndevice across requests without exposing the raw endpoint.\n\nAuthentication is required. The handler reads `data.user.id` from the session context,\nwhich is populated by upstream middleware that validates the bearer JWT. Unauthenticated\nrequests will be rejected before reaching this handler.\n\nNo rate limits are enforced at the handler level. Side effects: none, this is a read-only\noperation and produces no audit log entries.\n"
          },
          "response": []
        },
        {
          "name": "Revoke a specific push subscription by short id",
          "request": {
            "method": "DELETE",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v1/push/subscriptions",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "push",
                "subscriptions"
              ],
              "query": []
            },
            "description": "Removes a single browser push subscription from the server, identified by the 10-character\nhex short id returned by `GET /api/push/subscriptions`. The caller must supply a JSON body\ncontaining the target `id`.\n\nThe handler locates the subscription by scanning all subscriptions belonging to the\nauthenticated user and comparing the SHA-256 prefix of each stored endpoint against the\nsupplied id. If a match is found the row is deleted immediately; if no match exists a 404\nis returned.\n\nImportant: the browser-side Push API subscription is NOT unsubscribed by this operation.\nThe browser retains its local subscription object. The next push delivery attempt to the\nnow-deleted endpoint will receive a 410 response from the push service, which triggers\nthe server's delivery-cleanup path to prune any remaining stale state.\n\nAuthentication is required. The handler reads `data.user.id` and `data.user.email` from\nthe session context populated by upstream bearer-JWT middleware.\n\nOn success an audit log entry of action `push.device_revoke` is written, recording the\nacting user and the short id of the revoked subscription. No other side effects occur.\n\nNo rate limits are enforced at the handler level.\n",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"id\": \"string\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        }
      ]
    },
    {
      "name": "sales-lead",
      "item": [
        {
          "name": "Submit an enterprise sales lead inquiry",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v1/sales-lead",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "sales-lead"
              ],
              "query": []
            },
            "description": "Accepts an inbound enterprise lead from the public /sales/ page and emails\nthe contents to sales@abundera.ai via the Zepto transactional email transport.\nNo authentication is required, this endpoint is intentionally public so that\nprospects without accounts can submit inquiries.\n\nBefore any field validation is performed, the submission is screened with a\nCloudflare Turnstile token. This is the primary bot-prevention gate; invalid\nor missing tokens are rejected immediately with a 400 response so that bot\nsubmissions consume minimal compute.\n\nAfter passing Turnstile verification, the handler validates all required fields\n(name, email, company) and enforces maximum lengths on all accepted fields. The\nemail address is lowercased and checked against a basic format pattern. The\noptional `seats` field is accepted only when it is a non-negative integer; any\nother value causes it to be silently dropped rather than rejected.\n\nOn success the lead is dispatched as a plain-text email to sales@abundera.ai\nwith the submitter's email set as the Reply-To address. If the email dispatch\nfails for a transient reason the handler still returns a 200 response so the\nprospect does not see an error; the failure is logged server-side. The\nsubmission details are always written to the structured console log regardless\nof email outcome.\n\nThis endpoint is subject to the standard IP-based rate-limiting middleware\napplied across all API routes.\n",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"name\": \"string\",\n  \"email\": \"string\",\n  \"company\": \"string\",\n  \"use_case\": \"string\",\n  \"seats\": 0,\n  \"source\": \"string\",\n  \"turnstile\": \"string\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        }
      ]
    },
    {
      "name": "stats",
      "item": [
        {
          "name": "Retrieve recent scan activity across the caller's scope",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/stats/recent-scans?limit=20&hourly=1",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "stats",
                "recent-scans"
              ],
              "query": [
                {
                  "key": "limit",
                  "value": "20",
                  "description": "Maximum number of recent scan activity rows to return. Clamped to the range [1, 100]. Defaults to 20 if omitted or unparseable.\n",
                  "disabled": true
                },
                {
                  "key": "hourly",
                  "value": "1",
                  "description": "Pass `1` to request hourly-granularity data from the `scans_hourly` table instead of daily buckets. Only takes effect when the caller's plan includes hourly analytics; otherwise daily data is returned regardless of this value.\n",
                  "disabled": true
                }
              ]
            },
            "description": "Returns the most recently recorded scan activity rows within the authenticated\nuser's scope (individual, team, or agency). Because analytics are aggregated\ninto day-bucket rows rather than stored as individual scan events, \"recent\"\nrefers to the most recently updated daily aggregation buckets, up to the\nrequested limit.\n\nTeam and Agency plan holders may pass `?hourly=1` to switch the query to the\n`scans_hourly` table, providing finer-grained visibility into approximately the\nlast 24 hours of activity. Whether hourly mode is actually activated depends on\nboth the query parameter and the caller's plan; the response always reflects the\nmode that was used via `hourly_mode`, and advertises plan eligibility via\n`hourly_available`.\n\nAuthentication is required. The endpoint reads `data.user` to determine the\ncaller's plan and resolve the appropriate data shard for their scope. No\nwrite side-effects occur; this is a read-only operation.\n\nThe `limit` parameter is clamped server-side to the range [1, 100] regardless\nof the value supplied by the caller.\n"
          },
          "response": []
        },
        {
          "name": "Retrieve scan counts rolled up by tag for the caller's scope",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/stats/tag-rollups?days=30&top=20",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "stats",
                "tag-rollups"
              ],
              "query": [
                {
                  "key": "days",
                  "value": "30",
                  "description": "Trailing window in calendar days over which to aggregate scan counts. Clamped to [1, 365]. Defaults to 30.\n",
                  "disabled": true
                },
                {
                  "key": "top",
                  "value": "20",
                  "description": "Maximum number of tags to return, ordered by descending scan count. Clamped to [1, 100]. Defaults to 20.\n",
                  "disabled": true
                }
              ]
            },
            "description": "Returns the total number of QR-code scans grouped by tag for the\nauthenticated user's current scope (personal, team, or agency) over a\ntrailing window of N calendar days. This lets agencies compare campaign\nperformance across clients, e.g. \"client-a\" vs \"client-b\", without\nhaving to filter each individual QR code.\n\nThe scope is resolved automatically from the authenticated user's\nidentity and team membership via `resolveCodeScope`. The appropriate\nshard database is then selected with `dataDbForScope` before the\naggregation query is executed.\n\nBoth query parameters are clamped to safe ranges server-side: `days` is\nclamped to [1, 365] and defaults to 30; `top` is clamped to [1, 100]\nand defaults to 20. Non-finite or missing values fall back to the\nrespective defaults.\n\nAuthentication is required. The handler reads `data.user`, which is\npopulated by upstream authentication middleware that validates a bearer\nJWT. Requests without a valid token will be rejected before the handler\nis invoked.\n\nThis endpoint is read-only and has no side effects on stored data.\n"
          },
          "response": []
        }
      ]
    },
    {
      "name": "status",
      "item": [
        {
          "name": "Retrieve aggregated system status for Abundera QR Pro",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/status",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "status"
              ],
              "query": []
            },
            "description": "Returns a real-time aggregated status snapshot for the Abundera QR Pro product.\nEach request probes all registered component endpoints live (4-second timeout per\nprobe), combining the fresh probe results with historical uptime data stored in\nCloudflare KV under the key `cache:status`. The KV entry is written by a separate\nredirect-worker cron job that runs every five minutes and records status snapshots\nin the `status_snapshots` namespace.\n\nThe response is cached for 60 seconds in KV so that downstream consumers and\ndashboards do not hammer the upstream health endpoints. The two components probed\nare the redirect worker (https://aqr.net/health) and the Dashboard API itself\n(https://pro.qr.abundera.ai/api/health).\n\nThis endpoint is explicitly listed in the PUBLIC_API_PATHS set by the auth\nmiddleware and therefore requires no authentication. No request body or query\nparameters are accepted.\n",
            "auth": {
              "type": "noauth"
            }
          },
          "response": []
        },
        {
          "name": "Retrieve historical daily uptime and incidents",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/status/history?range=30d",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "status",
                "history"
              ],
              "query": [
                {
                  "key": "range",
                  "value": "30d",
                  "description": "Rolling look-back window for the history data. Accepted values are `7d` (7 days), `30d` (30 days), and `90d` (90 days). Defaults to `30d` when the parameter is absent.\n",
                  "disabled": true
                }
              ]
            },
            "description": "Returns a time-series of daily uptime percentages and associated incident\nrecords aggregated from the `status_snapshots` and `status_incidents` tables\n(introduced in migration 034). The caller selects a rolling look-back window\nvia the `range` query parameter; if omitted the default window is the last 30\ndays.\n\nResults are served from a KV cache (binding name `KV`) with a five-minute\nTTL. Cache misses trigger a live database read and a subsequent cache\npopulation before the response is returned.\n\nNo authentication is required; the endpoint is publicly accessible. No\nwrite side-effects occur, the handler is strictly read-only apart from the\nKV cache refresh.\n",
            "auth": {
              "type": "noauth"
            }
          },
          "response": []
        },
        {
          "name": "Subscribe an email address to status-page event alerts",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/status/subscribe",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "status",
                "subscribe"
              ],
              "query": []
            },
            "description": "Accepts an email address and a Cloudflare Turnstile token, validates the\nemail for correct format and against a disposable-domain blocklist, then\ncreates or refreshes a pending subscription record in D1 and sends a\nverification email via the Zepto transactional email transport.\n\nThe email must pass both structural validation and a disposable-domain\nhygiene check before Turnstile is verified or any database write occurs.\nIf the hygiene check detects a close-but-invalid address it may return a\nsuggested correction in the response body.\n\nNo authentication is required; the endpoint is protected by a Cloudflare\nTurnstile challenge instead.\n\nRate-limited to 3 requests per hour per originating IP address using a\nshared token-bucket helper backed by KV. Requests that exceed the limit\nreceive a 429 with a `Retry-After` header indicating when the bucket\nresets.\n\nSide effects: may write or update a subscriber row in D1 and enqueue a\ntransactional verification email to the supplied address.\n",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"email\": \"user@example.com\",\n  \"turnstileToken\": \"string\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            },
            "auth": {
              "type": "noauth"
            }
          },
          "response": []
        },
        {
          "name": "Verify a status-page subscription via emailed token",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/status/subscribe?token=string",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "status",
                "subscribe"
              ],
              "query": [
                {
                  "key": "token",
                  "value": "string",
                  "description": "Hex verification token delivered in the subscription confirmation email.",
                  "disabled": false
                }
              ]
            },
            "description": "Confirms a pending email subscription. The caller supplies the hex token\nthat was embedded in the verification email. If the token is valid and\nnot expired the subscription is marked as verified in D1 and the request\nis redirected with HTTP 302 to the status page at\n`https://pro.qr.abundera.ai/status/?verified=1`.\n\nNo authentication or Turnstile challenge is required; possession of the\nsingle-use token from the verification email is the proof of ownership.\n\nNo request body is expected; all input is carried in the query string.\n",
            "auth": {
              "type": "noauth"
            }
          },
          "response": []
        },
        {
          "name": "Unsubscribe an email address from status-page alerts",
          "request": {
            "method": "DELETE",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/status/subscribe?token=string",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "status",
                "subscribe"
              ],
              "query": [
                {
                  "key": "token",
                  "value": "string",
                  "description": "Hex token identifying the subscription to cancel.",
                  "disabled": false
                }
              ]
            },
            "description": "Removes an existing status-page email subscription identified by the\nhex token supplied in the query string. The token is the same value\ndelivered in the original verification email and any subsequent alert\nemails.\n\nNo authentication is required; possession of the unsubscribe token is\ntreated as sufficient proof of intent. No request body is expected.\n\nSide effects: deletes or deactivates the subscriber row in D1.\n",
            "auth": {
              "type": "noauth"
            }
          },
          "response": []
        }
      ]
    },
    {
      "name": "support",
      "item": [
        {
          "name": "Submit a Pro.qr support ticket",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v1/support",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "support"
              ],
              "query": []
            },
            "description": "Accepts support ticket submissions for the pro.qr product. Both authenticated\nand anonymous users may submit; a Cloudflare Turnstile token is always required\nas a bot-defence measure regardless of authentication state.\n\nWhen a valid session cookie is present the handler reads the caller's plan tier\nfrom the `users` table (falling back to the value embedded in the session JWT).\nTickets submitted by users on the `business`, `team`, or `agency` tiers are\nstored with `priority = \"high\"` and the outbound notification email is prefixed\nwith `[URGENT]` to surface them in the support inbox.\n\nEvery submission is persisted to the `support_tickets` D1 table and triggers a\ntransactional email to the address bound in `SUPPORT_TO`. The reply-to address\nis set to the authenticated account email when available, otherwise to the\nemail field supplied in the request body.\n\nRate limit: 5 submissions per IP per hour. This is intentionally looser than\nthe public contact form to accommodate Pro users who legitimately file several\ntickets (bug report, billing question, feature request) in a single session.\n\nRequires the `DB` and `SUPPORT_TO` environment bindings to be present; their\nabsence causes an immediate 500 response before any other processing occurs.\n",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"email\": \"user@example.com\",\n  \"purpose\": \"bug_report\",\n  \"message\": \"string\",\n  \"turnstile\": \"string\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        }
      ]
    },
    {
      "name": "teams",
      "item": [
        {
          "name": "List the current user's teams",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/teams",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "teams"
              ],
              "query": []
            },
            "description": "Returns all teams that the authenticated user belongs to, along with the user's currently\nactive team identifier.\n\nAuthentication is required. The handler reads `data.user` which is populated by upstream\nmiddleware that validates a bearer JWT. Unauthenticated requests will be rejected before\nthis handler is reached.\n\nNo plan restrictions apply to listing teams. Any authenticated user may call this endpoint\nregardless of their subscription tier.\n"
          },
          "response": []
        },
        {
          "name": "Create a new team for the current user",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v1/teams",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "teams"
              ],
              "query": []
            },
            "description": "Creates a new team owned by the authenticated user and returns the created team record.\n\nAuthentication is required. The handler reads `data.user`, which is populated by upstream\nmiddleware that validates a bearer JWT. Unauthenticated requests are rejected before this\nhandler is reached.\n\nTwo additional access checks are enforced before the team is created:\n\n1. **Writable account**, `requireWritableAccount` is called and will throw a structured\n   error (carrying `.status` and `.body`) if the account is read-only (e.g. a demo or\n   suspended account). That error is re-serialised and returned with its original HTTP\n   status code.\n\n2. **Team plan**, the user's plan is checked via `hasTeamAccess`. Only users on a\n   `team` or `agency` plan may create teams. Users on a lower-tier plan receive a 403\n   response with `error: \"insufficient_plan\"`.\n\nOn success the team is persisted to the database and returned with HTTP 201. The `name`\nfield is read from the request body; if the body is unparseable JSON the name will be\n`undefined` and the behaviour depends on `createTeam`'s own validation.\n",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"name\": \"string\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        },
        {
          "name": "Get team details and current user's membership role",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/teams/:id",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "teams",
                ":id"
              ],
              "query": []
            },
            "description": "Retrieves a team record along with the authenticated user's role within that team.\n\nThe team is looked up by the path parameter `id`. If no team exists with that identifier\na 404 is returned. The caller must be an existing member of the team; non-members receive\na 403 regardless of the team's existence.\n\nAuthentication is required. The handler reads `data.user` (populated upstream by a bearer\ntoken middleware) to identify the calling user.\n"
          },
          "response": []
        },
        {
          "name": "Rename a team or transfer ownership to another member",
          "request": {
            "method": "PATCH",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v1/teams/:id",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "teams",
                ":id"
              ],
              "query": []
            },
            "description": "Updates one or both of two independent team attributes in a single request:\n\n**Rename (`name`):** Changes the team's display name. Requires the caller to hold at\nleast the `admin` role. The name must be a non-empty string of at most 120 characters;\nviolating either constraint returns 400 with `invalid_name`.\n\n**Transfer ownership (`transfer_to_user_id`):** Reassigns the `owner` role from the\ncurrent caller to the specified user. Only the current `owner` may perform this action.\nThe target user must already be a member of the team (enforced by the `transferOwnership`\nhelper).\n\nBoth operations may be performed in the same request; ownership transfer is processed\nfirst. If the caller's role is insufficient for a requested operation, a 403 is returned.\nThe response always contains the latest team record after all mutations are applied.\n\nAuthentication is required via bearer token. The handler reads `data.user` (set by\nupstream middleware) to identify the calling user and enforce role checks.\n",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"name\": \"string\",\n  \"transfer_to_user_id\": \"string\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        },
        {
          "name": "Delete a team and nullify associated codes and user references",
          "request": {
            "method": "DELETE",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/teams/:id",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "teams",
                ":id"
              ],
              "query": []
            },
            "description": "Permanently deletes the specified team. Only the team `owner` may perform this action;\nany other role (or a non-member) results in a 403.\n\n**Cascade behaviour (defined at the database schema level):**\n\n* All team memberships and pending invites are removed via `ON DELETE CASCADE`.\n* QR codes whose `team_id` referenced this team are updated to `NULL` via\n  `ON DELETE SET NULL`, making them user-scoped (owned by their original creator).\n* Any user whose `current_team_id` pointed to this team is also set to `NULL` via\n  `ON DELETE SET NULL`.\n\nAfter the deletion is committed an audit-log entry with action `team.delete` is\nwritten, recording the acting user, the team ID, and the former team name.\n\nAuthentication is required via bearer token. The handler reads `data.user` (set by\nupstream middleware) to identify the calling user and enforce the owner role check.\n"
          },
          "response": []
        },
        {
          "name": "Retrieve team activity leaderboard and chronological feed",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/teams/:id/activity?days=30&limit=50",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "teams",
                ":id",
                "activity"
              ],
              "query": [
                {
                  "key": "days",
                  "value": "30",
                  "description": "Rolling window in days over which the leaderboard action counts are aggregated. Values below 1 are clamped to 1; values above 180 are clamped to 180. Non-numeric values fall back to the default of 30.\n",
                  "disabled": true
                },
                {
                  "key": "limit",
                  "value": "50",
                  "description": "Maximum number of entries to return in the chronological activity feed. Values below 1 are clamped to 1; values above 200 are clamped to 200. Non-numeric values fall back to the default of 50.\n",
                  "disabled": true
                }
              ]
            },
            "description": "Returns two complementary views of audit-log activity for the specified team:\na rollup leaderboard aggregated per user over a configurable rolling window, and\na chronological feed of individual audit-log entries.\n\nThe leaderboard always returns at most 25 users, sorted by action count descending.\nThe feed length is controlled by the `limit` query parameter (default 50, max 200).\nThe rolling window for the leaderboard is controlled by the `days` query parameter\n(default 30, max 180). Both parameters are clamped to their documented ranges; any\nnon-finite value (e.g. NaN from a non-numeric string) falls back to the default.\n\nAuthentication is required. The caller must supply a valid bearer JWT. The resolved\nuser must be an active member (role `member` or higher) of the requested team.\nNon-members receive a 403 response. Unauthenticated requests are rejected before\nthe handler is reached by the authentication middleware that populates `data.user`.\n\nNo mutations are performed; this endpoint is read-only and produces no side effects.\n"
          },
          "response": []
        },
        {
          "name": "Retrieve aggregated heatmap analytics for a team",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/teams/:id/heatmaps?days=365",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "teams",
                ":id",
                "heatmaps"
              ],
              "query": [
                {
                  "key": "days",
                  "value": "365",
                  "description": "Number of past days to include in the calendar dataset. Defaults to 365. Values below 1 are coerced to 365. Values above 1095 are capped at 1095. Additionally capped by the plan-level analytics retention limit.\n",
                  "disabled": true
                }
              ]
            },
            "description": "Returns three heatmap datasets aggregated across all QR codes belonging to the\nspecified team: a daily calendar of scan counts, an hourly-by-day-of-week grid,\nand a geographic breakdown by country.\n\nThe caller must be an authenticated team member (role of \"member\" or higher).\nAuthentication is performed via a bearer JWT, and the resolved user identity is\nused both for membership verification and for determining the plan-based data\nretention cap.\n\nThe `calendar` dataset covers up to the requested `days` window, zero-filled for\ndays with no scans. The window is capped at 1095 days and further constrained by\nthe plan-level retention limit (`ANALYTICS_DAYS[plan]`; default 365 days).\n\nThe `hourly_dow` grid (day-of-week \u00d7 hour-of-day scan totals over the last 90\ndays) is only populated when the team owner's plan includes hourly analytics\n(Team/Agency tier). For lower-tier plans the array is returned empty and\n`hourly_available` is set to `false`.\n\nThe `geo` dataset lists up to 50 countries by descending scan volume. Countries\nwhose total falls below a noise floor of 5 scans are collapsed into a synthetic\n\"Other\" entry. No plan gate is applied to the calendar or geo datasets.\n\nNo mutations are performed; this endpoint is read-only.\n"
          },
          "response": []
        },
        {
          "name": "List pending invites for a team",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/teams/:id/invites",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "teams",
                ":id",
                "invites"
              ],
              "query": []
            },
            "description": "Returns all pending (not yet accepted or expired) invites for the specified team.\n\nThe caller must be an authenticated user (bearer JWT required) and must hold at\nleast the `admin` role within the team. The membership check and role assertion\nare performed before any invite data is returned.\n\nIf the caller is not a member of the team, or does not hold the `admin` role,\na 403 response is returned. Internal errors fall back to a generic 500 response.\n"
          },
          "response": []
        },
        {
          "name": "Create and email a team invite",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v1/teams/:id/invites",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "teams",
                ":id",
                "invites"
              ],
              "query": []
            },
            "description": "Creates a new pending invite for the specified email address and role, then\nattempts to deliver an invite email via ZeptoMail. The invite record is\npersisted in D1 before the email is sent, so the invite remains valid even\nif email delivery fails. The admin can re-send by repeating the request.\n\nThe caller must be an authenticated user (bearer JWT required) and must hold\nat least the `admin` role within the team. The membership check and role\nassertion are performed before any invite is created.\n\nAs a best-effort side effect, if the invited email address already corresponds\nto a registered user account and that user is not the inviting admin, a push\nnotification is dispatched to that user on the `team_invite` topic. This push\nis fire-and-forget and will not cause the request to fail if it errors.\n\nOn success the newly created invite object is returned with HTTP 201. If the\ncaller lacks the admin role a 403 is returned. Validation or business-logic\nerrors surfaced by the team library are forwarded with their original status\ncode and body. Internal errors fall back to a generic 500 response.\n",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"email\": \"user@example.com\",\n  \"role\": \"string\",\n  \"team_name\": \"string\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        },
        {
          "name": "Revoke a pending team invite",
          "request": {
            "method": "DELETE",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/teams/:id/invites/:invite_id",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "teams",
                ":id",
                "invites",
                ":invite_id"
              ],
              "query": []
            },
            "description": "Permanently revokes a pending invite identified by `invite_id` within the team identified by `id`.\nThe invite record is looked up by both its own ID and the team ID to prevent cross-team access.\n\nAuthentication is required. The request must carry a valid bearer JWT; the resolved user identity\nis available on `data.user`. The caller must be a member of the team with at least the `admin` role ,\nlower-privileged members (e.g. `member`) will receive a 403 response.\n\nOn success the invite is deleted from the `team_invites` table and an audit log entry of type\n`invite.revoke` is written, recording the acting user, the team, and the email address that had\nbeen invited.\n\nNo rate limits are enforced at the handler level. Side effects: invite deletion is permanent and\ncannot be undone through this API; the invited address would need to be re-invited.\n"
          },
          "response": []
        },
        {
          "name": "List members and roles for a team",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/teams/:id/members",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "teams",
                ":id",
                "members"
              ],
              "query": []
            },
            "description": "Returns all members belonging to the specified team, including their roles.\n\nThe caller must be an authenticated user with at least the `member` role in\nthe target team. Authentication is established via a bearer JWT which the\nframework resolves into `data.user` before the handler runs. If the caller\nis not a member of the team (or has insufficient role), the error returned\nby `requireRole` is forwarded directly with its associated HTTP status code.\n\nNo side effects are produced by this endpoint; it is a pure read operation\nagainst the database. No rate limits are enforced by the handler itself.\n"
          },
          "response": []
        },
        {
          "name": "Change a team member's role",
          "request": {
            "method": "PATCH",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v1/teams/:id/members/:user_id",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "teams",
                ":id",
                "members",
                ":user_id"
              ],
              "query": []
            },
            "description": "Updates the role of a specific team member identified by `user_id` within the team identified by `id`.\n\nThe authenticated user must be a member of the team with at least the `admin` role to perform this operation.\nThe target member's previous role is captured before the update so that the resulting webhook event can\ncommunicate whether the change was a promotion or a demotion.\n\nOwners cannot have their role changed through this endpoint; the underlying `changeRole` helper enforces\nthis restriction and will return an appropriate error response.\n\nA `team.role_changed` webhook event is dispatched asynchronously after a successful role change. The payload\nincludes the team ID, the affected user ID, the previous role, the new role, and the ID of the acting user.\n\nAuthentication is required via a bearer JWT. The resolved user is available on `data.user`.\n",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"role\": \"string\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        },
        {
          "name": "Remove a member from a team or leave the team",
          "request": {
            "method": "DELETE",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/teams/:id/members/:user_id",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "teams",
                ":id",
                "members",
                ":user_id"
              ],
              "query": []
            },
            "description": "Removes the team member identified by `user_id` from the team identified by `id`.\n\nThere are two permitted actors for this operation:\n\n1. **Admins (and above):** Any member of the team holding at least the `admin` role may remove any other\n   non-owner member.\n2. **Self-leave:** A member may remove themselves from the team regardless of their own role, as long as\n   they are not the team owner. Owners must transfer ownership before they can leave.\n\nThe target member's role at the time of removal is captured and included in the outgoing webhook event.\nA `team.member_removed` webhook event is dispatched asynchronously after a successful removal. The payload\nincludes the team ID, the removed user ID, their previous role, the ID of the acting user, and a boolean\n`self_leave` flag indicating whether the removal was voluntary.\n\nAttempting to remove the team owner (via either path) is rejected by the underlying `removeMember` helper.\n\nAuthentication is required via a bearer JWT. The resolved user is available on `data.user`.\n"
          },
          "response": []
        },
        {
          "name": "Get public stats sharing state for a team",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/teams/:id/public-stats",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "teams",
                ":id",
                "public-stats"
              ],
              "query": []
            },
            "description": "Returns the current public-stats sharing state for the specified team, including\nwhether a shareable token is active and the corresponding share URL if one exists.\n\nAuthentication is required. The caller must be an authenticated user and a member\nof the team (any role, including \"member\"). Non-members receive a 403 error.\n\nThis endpoint has no side effects, it is purely read-only and does not modify\nany tokens or sharing state.\n"
          },
          "response": []
        },
        {
          "name": "Mint a new shareable public stats token for a team",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v1/teams/:id/public-stats",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "teams",
                ":id",
                "public-stats"
              ],
              "query": []
            },
            "description": "Mints a new shareable public-stats token for the specified team, granting\nanonymous read-only access to the team's aggregated analytics at /p/<token>/.\nThe generated share URL exposes no per-code destination data and no personally\nidentifiable information.\n\nAuthentication is required. The caller must be the team owner. Team members\nwith any lesser role (e.g. \"member\") are not permitted to mint tokens, because\nminting is destructive: any previously issued share URL for this team is\nimplicitly revoked and replaced by the new token. This prevents a plain member\nfrom silently rotating the team-wide share link.\n\nOn success, the action is recorded in the audit log as `team.public_stats.enable`.\n",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"data\": null\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        },
        {
          "name": "Revoke all public stats share tokens for a team",
          "request": {
            "method": "DELETE",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/teams/:id/public-stats",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "teams",
                ":id",
                "public-stats"
              ],
              "query": []
            },
            "description": "Revokes all active public-stats share tokens for the specified team, immediately\ninvalidating any previously issued share URLs at /p/<token>/. After this operation,\nno anonymous party can access the team's aggregated analytics until a new token is\nminted via POST.\n\nAuthentication is required. The caller must be the team owner. Team members with\nany lesser role are not permitted to revoke tokens, for the same reason they cannot\nmint them, rotating or disabling the team-wide share link is a privileged action.\n\nOn success, the action is recorded in the audit log as `team.public_stats.disable`.\nThe response contains a `revoked: true` confirmation field.\n"
          },
          "response": []
        },
        {
          "name": "Get rollup statistics for a team",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/teams/:id/stats",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "teams",
                ":id",
                "stats"
              ],
              "query": []
            },
            "description": "Returns aggregated statistics for the specified team, including QR code counts\nbroken down by status, scan counts over the last 30 and 90 days, member list,\nand seat usage metrics.\n\nAuthentication is required via a bearer JWT. The authenticated user must be a\nmember of the team (any role, including \"member\") to access this endpoint.\nMembership and role are verified before any data is returned; users who are not\nmembers of the requested team receive a 403 error.\n\nResults are served from a per-scope stats cache when available. On a cache miss\nthe stats are computed from the shard database selected for the team/user scope,\nand the computed result is written back to the cache asynchronously (via\n`waitUntil` when available, otherwise fire-and-forget). The response includes a\n`cache` object indicating whether the result was a cache hit or miss.\n\nThe depth or limits of certain statistics (e.g. scan windows) may vary based on\nthe authenticated user's plan.\n"
          },
          "response": []
        }
      ]
    },
    {
      "name": "user",
      "item": [
        {
          "name": "Switch the signed-in user's active team context",
          "request": {
            "method": "PATCH",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v1/user/current-team",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "user",
                "current-team"
              ],
              "query": []
            },
            "description": "Updates the authenticated user's `current_team_id` field, which controls which team\nscope the user is acting within for subsequent requests.\n\nPassing a valid team UUID sets that team as the active context. The user must already\nbe a member of the specified team; if they are not, the request is rejected with a 403.\n\nPassing `null` (or omitting `team_id`) clears the active team context, placing the user\nin their personal scope. Any value that is neither a string nor `null` is rejected with\na 400.\n\nAuthentication is required. The handler reads `data.user.id` from middleware-populated\nsession data, which implies a valid bearer JWT must accompany the request. No explicit\nrate limits are enforced at the handler level.\n\nSide effects: the `users` table row for the authenticated user is updated in place,\nsetting `current_team_id` and refreshing `updated_at` to the current Unix timestamp.\n",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"team_id\": null\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        },
        {
          "name": "Export complete account data as a ZIP archive",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/user/export",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "user",
                "export"
              ],
              "query": []
            },
            "description": "Returns a ZIP archive containing the authenticated user's complete account\ndata. The archive includes three files: `codes.csv` (every dynamic QR code\never created for the account, including grace-period and expired codes),\n`scans.csv` (aggregated daily scan counts per code, country, and device\ntype), `README.txt` (a column guide and re-import instructions), and\n`MANIFEST.json` (a portable reconstitution manifest with format version,\nper-file SHA-256 digests, shortcode derivation parameters, and account\nmetadata per Abundera patent provisional QR-05).\n\nAuthentication is required. The handler reads `data.user` which is\npopulated upstream by session middleware; unauthenticated requests will\nbe rejected before the handler runs.\n\nThe user's data is fetched from the shard database determined by\n`dataDbForUser`. Both the codes and scans queries are scoped strictly to\nthe authenticated user's `user_id`.\n\nAs a side effect, a GDPR audit-log entry is written fire-and-forget to\n`env.DB` recording the user ID, email, action (`export`), source\n(`self_service`), timestamp, code count, scan row count, and ZIP byte\nsize. A failure in this log write does not block delivery of the archive.\n\nThe response is not cacheable (`Cache-Control: no-store`). The\n`Content-Disposition` header sets a filename of the form\n`abundera-qr-pro-YYYY-MM-DD.zip` based on the UTC date at request time.\n\nNo rate limit is documented in the handler source, but the operation\nperforms two database queries and computes three SHA-256 digests before\nstreaming the ZIP, so callers should avoid issuing this request in tight\nloops.\n"
          },
          "response": []
        },
        {
          "name": "Soft-delete the authenticated user's account",
          "request": {
            "method": "DELETE",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v1/user/me",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "user",
                "me"
              ],
              "query": []
            },
            "description": "Marks the authenticated user's account as pending deletion by setting\n`plan_status` to `pending_delete` and recording `delete_requested_at`.\nThe account is not immediately removed; a scheduled daily sweeper in the\nredirect worker performs the hard delete 30 days after the request,\npurging QR codes, scans, API keys, the user row, and tombstoning every\nassociated KV entry.\n\nThe caller must supply `{ \"confirm\": \"DELETE\" }` in the JSON request body\nas an explicit acknowledgement. Omitting or misspelling this field returns\na 400 error and leaves the account untouched.\n\nStripe subscription cancellation is not performed by this endpoint and\nshould be completed via the Customer Portal prior to deletion (or will be\nhandled independently via webhook).\n\nA GDPR audit-log entry is written asynchronously (fire-and-forget) with\naction `delete` and source `self_service`, recording the scheduled purge\ntimestamp. A second audit entry for `hard_delete` is written by the daily\nsweeper after the 30-day hold expires.\n\nRequires a valid bearer JWT. The resolved user is expected to be present\non `data.user` as populated by upstream authentication middleware.\n",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"confirm\": \"DELETE\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        },
        {
          "name": "Get current month's scan usage for the authenticated user",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/user/scan-usage",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "user",
                "scan-usage"
              ],
              "query": []
            },
            "description": "Returns scan usage statistics for the currently authenticated user for the\npresent calendar month. The month is determined server-side in UTC and\nformatted as `YYYY-MM`.\n\nThe scan count is read from a shared KV namespace where the key\n`mscan:<user_id>:<YYYY-MM>` is incremented by the `qr-redirect-worker`\neach time one of the user's QR codes is scanned. A separate sentinel key\n`mscan-sent:<user_id>:<YYYY-MM>` is checked to determine whether a\ncap-crossed notification has already been dispatched for this month.\n\nThe `cap` field reflects the plan's advertised monthly scan limit as\ndefined in the server-side plan configuration. A `null` cap indicates an\nunlimited plan. The `over` flag is `true` when the cap is non-null and the\ncurrent count meets or exceeds it.\n\nAuthentication is required. The handler reads `data.user` which is\npopulated by upstream middleware that validates the bearer JWT. Requests\nwithout a valid token will be rejected before reaching this handler.\n\nThis endpoint has no side effects; it performs read-only KV lookups. If\nthe KV namespace is unavailable the handler recovers gracefully and returns\na count of `0` with `over: false`.\n"
          },
          "response": []
        },
        {
          "name": "Retrieve usage stats for the caller's current scope",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/user/scope-stats",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "user",
                "scope-stats"
              ],
              "query": []
            },
            "description": "Returns aggregated usage statistics for the authenticated user's active scope. The\nscope is resolved automatically: if the user has a `current_team_id` and a valid team\nmembership that scope is used (type `team`); otherwise the user's personal workspace\nscope is used (type `user`). This means the dashboard can make a single call without\nneeding to branch on scope resolution itself.\n\nAuthentication is required. The handler reads `data.user` which is populated by an\nupstream authentication middleware that validates a bearer JWT. Requests without a\nvalid token will be rejected before the handler is invoked.\n\nThe endpoint implements a read-through cache introduced in migration 019. On a cache\nhit the pre-computed stats object is returned immediately along with `cache.hit: true`.\nOn a miss, live stats are computed from the appropriate shard database, the result is\nreturned to the caller with `cache.hit: false`, and the cache is written back\nasynchronously (via `waitUntil` where available) so subsequent callers benefit from\nthe cached value. No rate limiting or quota enforcement is applied inside this handler.\n"
          },
          "response": []
        },
        {
          "name": "Set or clear the user's virtual-workspace display label",
          "request": {
            "method": "PATCH",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v1/user/workspace-label",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "user",
                "workspace-label"
              ],
              "query": []
            },
            "description": "Updates the authenticated user's personal workspace display label, the name\nshown in place of a team name when the user operates in personal (non-team)\nscope. No team row is created; the value is stored directly on the user record.\n\nPassing a non-empty string sets the label (trimmed, capped at 120 characters).\nPassing `null`, an empty string `\"\"`, or a whitespace-only string clears the\nlabel back to the default \"My workspace\" behaviour by setting the column to\n`NULL`.\n\nRequires a valid bearer JWT. The handler reads `data.user.id` to identify the\nauthenticated user, so requests without a valid session will be rejected by\nupstream middleware before reaching this handler.\n\nNo explicit rate limit is enforced within this handler beyond what the platform\napplies globally. The only side effect is an `UPDATE` to the `users` table\n(`workspace_label` and `updated_at` columns).\n",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"workspace_label\": null\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        },
        {
          "name": "Get personal workspace statistics for the signed-in user",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/user/workspace-stats",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "user",
                "workspace-stats"
              ],
              "query": []
            },
            "description": "Returns a rollup of QR code and scan statistics scoped to the authenticated\nuser's personal workspace, that is, codes where no team is assigned\n(team_id IS NULL). This mirrors the shape returned by the team stats\nendpoint but applies only to the user's own codes.\n\nAuthentication is required. The handler reads the resolved `data.user`\nobject, which is populated upstream by bearer-token middleware. Requests\nwithout a valid JWT will be rejected before reaching this handler.\n\nThe response includes all scan and code metrics produced by the shared\n`statsForScope` utility, supplemented by the user's `workspace_label` if\none has been set on the account. No member list is included because a\npersonal workspace has no team members.\n\nNo write side-effects are produced by this endpoint. Rate limits and quotas\nare governed by the user's plan, as passed to the underlying stats query.\n"
          },
          "response": []
        }
      ]
    },
    {
      "name": "user/weather-overlay",
      "item": [
        {
          "name": "Correlate daily scan volume with weather for top countries",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/user/weather-overlay?days=30",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "user",
                "weather-overlay"
              ],
              "query": [
                {
                  "key": "days",
                  "value": "30",
                  "description": "Number of trailing calendar days to include in the analysis window. Clamped to the range [7, 90]. Defaults to 30.\n",
                  "disabled": true
                }
              ]
            },
            "description": "Returns a time-series overlay of daily QR-code scan counts alongside daily\nmean temperature (\u00b0C) and total precipitation (mm) for the scope's top-3\ncountries by scan volume over the requested window.\n\nThe handler resolves the caller's team or personal scope, queries the shard\ndatabase for the top-3 countries ranked by total scans in the window, then\nfetches historical weather from the open-meteo free archive API\n(archive-api.open-meteo.com) using fixed centroid coordinates for each\ncountry. Weather data is cached in KV under a per-(country, date-range) key\nwith a 24-hour TTL; only countries present in the built-in centroid table\nare eligible for weather data.\n\nFor each country, Pearson correlation coefficients are computed between the\ndaily scan series and each of the temperature and precipitation series.\nCountries whose ISO code is not in the centroid table are still returned but\nwith `available: false` and no weather or correlation fields.\n\nAuthentication is required; the handler reads `data.user` which is populated\nby upstream bearer-token middleware. Open-meteo is called without a customer\nAPI key; its free tier allows up to 10 000 calls per day and receives only\n(latitude, longitude, date range), no user-identifying information.\n\nThe `days` window is clamped to the range [7, 90]. If no scan data exists\nwith country information in the window, the response body contains\n`available: false` with reason `no_country_data` at the top level.\n"
          },
          "response": []
        }
      ]
    },
    {
      "name": "verify-phone",
      "item": [
        {
          "name": "Confirm phone OTP and mark the account as phone-verified",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v1/verify/phone/confirm",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "verify",
                "phone",
                "confirm"
              ],
              "query": []
            },
            "description": "Second leg of the Twilio Verify SMS OTP flow. The caller submits the E.164\nphone number and the 6-digit (or 4\u201310 digit) code that was delivered by SMS\nduring the `/api/verify/phone/start` step. The handler validates both inputs,\nenforces a US-only geo-gate via the `CF-IPCountry` header, then asks Twilio\nVerify whether the supplied code is approved for that number.\n\nOn a successful Twilio check the handler:\n- Computes `SHA-256(phone_e164)` to produce a `phone_hash`.\n- Rejects the request with `409` if any *other* already-verified account\n  carries the same hash, preventing one physical number from unlocking\n  multiple accounts.\n- Writes `phone_verified = 1`, `phone_verified_at`, `phone_hash`, and\n  `phone_last4` to the authenticated user's record.\n- Appends a `phone.verified` entry to the audit log.\n\nCompleting this step unlocks the Free-tier 3-code generation ceiling\n(equivalent to the Stripe $0 SetupIntent path) as evaluated by\n`codesLimitFor()` in `lib/plan.js`.\n\n**Authentication**: a valid bearer JWT is required; the handler reads\n`data.user` injected by upstream auth middleware.\n\n**Rate limiting**: a per-user sliding window of 10 confirm attempts per\nhour is enforced in addition to Twilio Verify's own attempt cap for the\noutstanding verification SID.\n\n**Geo restriction**: requests whose `CF-IPCountry` header is not `US` are\nrejected with `400 country_not_supported`, mirroring the restriction in the\nstart step.\n",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"phone_e164\": \"string\",\n  \"code\": \"string\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        },
        {
          "name": "Start a Twilio Verify OTP flow for a US phone number",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v1/verify/phone/start",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "verify",
                "phone",
                "start"
              ],
              "query": []
            },
            "description": "Initiates the first leg of the phone-number OTP verification flow by sending an SMS via\nTwilio Verify. The caller must be an authenticated user (bearer JWT required); the endpoint\nreads `data.user` from the middleware-populated request context.\n\nSix layered checks are applied in order, each designed to reject bad requests before\nincurring cost from the next tier:\n\n1. **Regex prefilter**, validates E.164 format and rejects non-US, toll-free, and premium\n   numbers at zero cost.\n2. **IP-country gate**, rejects requests where the `CF-IPCountry` header is not `US`.\n3. **Per-user rate limit**, at most 3 verification starts per user per hour, enforced via\n   Cloudflare KV.\n4. **Per-phone-hash rate limit**, at most 5 starts per unique phone number (stored as a\n   SHA-256 hash) per day, preventing phone-exhaustion attacks across multiple accounts.\n5. **Twilio Lookup lineTypeIntelligence**, checks the number's line type ($0.008, cached\n   90 days in KV by phone hash). VoIP and unsupported line types are rejected here.\n6. **Twilio Verify send**, the billable SMS dispatch (~$0.008).\n\nOn success, a `phone.verify_start` event is written to the audit log. The phone number is\nnever stored in plaintext; only its SHA-256 hash is persisted in KV. If the user has\nalready completed verification (`phone_verified = 1`), the request is rejected immediately\nwithout consuming any rate-limit quota.\n\nA successful downstream confirmation (POST /api/verify/phone/confirm) sets\n`users.phone_verified = 1`, which raises the Free-tier code-generation ceiling from 1 to 3.\n",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"phone_e164\": \"+12065551234\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        }
      ]
    },
    {
      "name": "webhooks",
      "item": [
        {
          "name": "List webhooks for the current scope",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/webhooks",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "webhooks"
              ],
              "query": []
            },
            "description": "Returns all webhooks registered under the caller's resolved code scope\n(personal or team, determined server-side from the authenticated user\nrecord). Each webhook entry includes its target URL, subscribed event\ntypes, active flag, optional description, and last-delivery metadata.\n\nThe response also includes the full list of supported event type strings\nso clients can populate a UI without a separate discovery call.\n\nAuthentication is required. The handler reads `data.user`, which is\npopulated by upstream JWT bearer-token middleware. Requests without a\nvalid bearer token will be rejected before reaching this handler.\n\nThe signing secret is never returned by this endpoint; it is only\navailable at creation time.\n"
          },
          "response": []
        },
        {
          "name": "Create a new webhook for the current scope",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v1/webhooks",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "webhooks"
              ],
              "query": []
            },
            "description": "Registers a new webhook endpoint under the caller's resolved code scope.\nThe scope (personal or team) is determined server-side from the\nauthenticated user record.\n\nThe request body must supply a target URL and at least a list of event\ntypes to subscribe to. An optional human-readable description may also\nbe provided.\n\nOn success the response includes the full webhook record **plus the raw\nsigning secret**, which is returned exactly once at creation time and\nis never reflected back by any subsequent API call. Callers must\nrecord this secret immediately and use it to verify the\n`webhook-signature` header on incoming deliveries (Standard Webhooks:\n`webhook-id`, `webhook-timestamp`, `webhook-signature: v1,<base64>`).\n\nAuthentication is required. The handler reads `data.user`, which is\npopulated by upstream JWT bearer-token middleware. Requests without a\nvalid bearer token will be rejected before reaching this handler.\n\nValidation errors thrown by the `createWebhook` helper (e.g. invalid\nURL, unrecognised event types) are propagated with whatever HTTP status\nthe error carries; unrecognised errors fall back to 500.\n",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"url\": \"string\",\n  \"events\": [\n    \"string\"\n  ],\n  \"description\": \"string\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        },
        {
          "name": "Delete a webhook",
          "request": {
            "method": "DELETE",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/webhooks/:id",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "webhooks",
                ":id"
              ],
              "query": []
            },
            "description": "Permanently delete a webhook from the caller's resolved code scope.\n\nDelivery to the URL stops immediately. Past delivery-log rows for this\nwebhook remain in the database (for audit purposes) but are no longer\nreachable from the API.\n\nAuthentication is required. The handler reads `data.user`, which is\npopulated by upstream JWT bearer-token middleware. Requests without a\nvalid bearer token will be rejected before reaching this handler.\n"
          },
          "response": []
        }
      ]
    }
  ]
}
