{
  "openapi": "3.1.0",
  "info": {
    "title": "Abundera QR Pro API",
    "version": "1.0.0",
    "description": "Customer API for pro.qr.abundera.ai. Covers code management, analytics, groups, webhooks, team management, and user-scope data.\n\n## Not in this spec\n\n- Admin endpoints (`/api/admin/*`) \u2014 service-to-service only, authenticated via `X-Service-Secret` header.\n- Stripe webhook inbound (`/api/stripe/webhook`) \u2014 signed by Stripe, not customer-callable.\n- Invite accept-bridge (`/api/invites/:token/register-bridge`) \u2014 service-to-service from abundera.ai.\n- Share-page data endpoints (`/api/p/:token/*`, `/api/codes/:id/public-stats`) \u2014 power the public preview pages, not part of the programmatic API surface.\n- Session endpoints (`/api/auth/*`, `/api/billing/*`, `DELETE /api/user/me`, `PATCH /api/user/current-team`) \u2014 cookie-authenticated only, used by the dashboard; API keys do not authorize them.\n\n## Authentication\n\nPrimary: Bearer token with `abnd_qrpro_`-prefixed API key. Create at [abundera.ai/account/api-keys/](https://abundera.ai/account/api-keys/) (Business, Team, Agency plans) \u2014 keys are federated across every Abundera product (ADR 076). A key request to a cookie-only endpoint returns `403 key_not_permitted`.\n\nAlternative: session cookie (dashboard). API consumers should use the bearer token.\n\n## Scopes\n\nEvery API key is minted with an explicit scope set. Pro.qr enforces scopes at the middleware layer; a request with a valid key but the wrong scope returns `403 { \"error\": \"scope_missing\", \"required\": \"<scope>\" }`. Legal scopes: `codes:read`, `codes:write`, `stats:read`, `groups:write`, `webhooks:write`, `teams:read`, `teams:write`. An empty-scope key (legacy, pre-federation) has full access.\n\n## Account-state gate\n\nAPI keys stop working immediately when the account is terminated (`plan_status` = `expired` or `pending_delete`) \u2014 such requests return `401 { \"error\": \"account_inactive\", \"plan_status\": \"<state>\" }`. Keys remain fully functional during the 90-day `grace` window so customers can export and migrate. Cookie sessions remain valid in all states so users can still reactivate or confirm deletion.\n\n## Rate limits\n\n- API-key requests: 1000/day (Business), 10000/day (Team), 50000/day (Agency).\n- Session-cookie requests: 60/minute.\n\nOn 429 responses: `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `Retry-After`.\n\n## Webhook delivery\n\nYou can register outbound webhooks at `/api/webhooks`. Deliveries are HMAC-SHA256-signed (Stripe-compatible format): `X-Abundera-Signature: t=<unix_ts>,v1=<hex>`. Signed payload = `\"{timestamp}.{raw_body}\"`. See `components.securitySchemes.webhookSignature` for the scheme.",
    "contact": {
      "name": "Abundera support",
      "email": "support@abundera.ai",
      "url": "https://pro.qr.abundera.ai/docs/"
    },
    "license": {
      "name": "Commercial",
      "url": "https://pro.qr.abundera.ai/legal/terms/"
    }
  },
  "servers": [
    {
      "url": "https://pro.qr.abundera.ai",
      "description": "Production"
    }
  ],
  "security": [
    {
      "bearerAuth": []
    }
  ],
  "tags": [
    {
      "name": "Codes",
      "description": "Create, list, update, delete dynamic QR codes."
    },
    {
      "name": "Analytics",
      "description": "Scan analytics \u2014 rollups, time series, heatmaps, deep analysis."
    },
    {
      "name": "Groups",
      "description": "Organize codes into named groups (campaigns)."
    },
    {
      "name": "Teams",
      "description": "Team plan: members, roles, invites, activity log."
    },
    {
      "name": "Webhooks",
      "description": "Outbound event delivery to customer endpoints."
    },
    {
      "name": "User",
      "description": "Current user profile, workspace settings, usage."
    },
    {
      "name": "Health",
      "description": "Platform health."
    },
    {
      "name": "Org",
      "description": "White-label org config \u2014 Enterprise tier only. Custom branding, short-URL domain, and dashboard domain. Internal admin endpoints documented in docs/openapi-internal.json."
    }
  ],
  "paths": {
    "/api/health": {
      "get": {
        "tags": ["Health"],
        "summary": "Health check",
        "security": [],
        "responses": {
          "200": {
            "description": "Service healthy.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ok": {
                      "type": "boolean"
                    },
                    "sweeper": {
                      "type": "object"
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/codes": {
      "get": {
        "tags": ["Codes"],
        "summary": "List codes",
        "description": "Paginated list of codes in the caller's current scope (personal workspace or active team).",
        "parameters": [
          {
            "name": "page",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "default": 1
            }
          },
          {
            "name": "page_size",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100,
              "default": 20
            }
          },
          {
            "name": "q",
            "in": "query",
            "description": "Free-text search over label / URL / tags.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "tag",
            "in": "query",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "status",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": ["active", "paused", "grace", "expired"]
            }
          },
          {
            "name": "group",
            "in": "query",
            "description": "Filter by group id.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "sort",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": ["recent", "oldest", "label", "scans"]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "List of codes.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "codes": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Code"
                      }
                    },
                    "pagination": {
                      "$ref": "#/components/schemas/Pagination"
                    },
                    "scope": {
                      "$ref": "#/components/schemas/Scope"
                    },
                    "plan": {
                      "type": "string"
                    },
                    "plan_limit": {
                      "type": "integer"
                    }
                  }
                }
              }
            }
          }
        }
      },
      "post": {
        "tags": ["Codes"],
        "summary": "Create a code",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CodeCreate"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Created.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Code"
                }
              }
            }
          },
          "402": {
            "description": "Plan limit reached."
          }
        }
      }
    },
    "/api/codes/{id}": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "tags": ["Codes"],
        "summary": "Get a code",
        "responses": {
          "200": {
            "description": "The code.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Code"
                }
              }
            }
          },
          "404": {
            "description": "Not found."
          }
        }
      },
      "patch": {
        "tags": ["Codes"],
        "summary": "Update a code",
        "description": "Modify url, label, tags, status (active/paused), qr design fields, tag/group assignment, or move between scopes (team_id/null).",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CodePatch"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated."
          }
        }
      },
      "delete": {
        "tags": ["Codes"],
        "summary": "Soft-delete a code (enters 90-day grace).",
        "responses": {
          "200": {
            "description": "Grace started."
          }
        }
      }
    },
    "/api/codes/check-slug": {
      "get": {
        "tags": ["Codes"],
        "summary": "Preflight custom-slug availability",
        "description": "Checks whether a custom shortcode is available to the current user. Used by UI for live input validation. Runs the same validation chain as POST /api/codes but performs no mutation. Cheap read-only endpoint; does not consume rate-limit quota. Returns `{ available: false, reason }` with one of: `invalid` (bad alphabet / hyphen hygiene / length), `plan_not_eligible` (Free or Keep-Alive), `tier_too_short` (slug below plan minimum; includes `min_length`), `cap_reached` (user hit MAX_CUSTOM_SLUGS; includes `max` + `used`), `reserved` (slug is in shortcode_reservations and not granted to this user), `taken` (already claimed).",
        "parameters": [
          {
            "name": "slug",
            "in": "query",
            "required": true,
            "description": "Proposed custom shortcode. Lowercase + digits + hyphen, 1-32 chars, no leading/trailing/consecutive hyphens.",
            "schema": {
              "type": "string",
              "minLength": 1,
              "maxLength": 32
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Availability decision. Status 200 regardless of availability \u2014 always inspect the `available` field.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["available"],
                  "properties": {
                    "available": {
                      "type": "boolean"
                    },
                    "reason": {
                      "type": "string",
                      "enum": [
                        "invalid",
                        "plan_not_eligible",
                        "tier_too_short",
                        "cap_reached",
                        "reserved",
                        "taken"
                      ]
                    },
                    "slug": {
                      "type": "string",
                      "description": "Normalized (lowercased) form of the input; present when available=true."
                    },
                    "plan": {
                      "type": "string",
                      "description": "User's current plan, included when reason=plan_not_eligible or tier_too_short."
                    },
                    "min_length": {
                      "type": "integer",
                      "description": "Minimum slug length the user's plan permits; present when reason=tier_too_short."
                    },
                    "max": {
                      "type": "integer",
                      "description": "Custom-slug cap for the user's plan; present when reason=cap_reached."
                    },
                    "used": {
                      "type": "integer",
                      "description": "User's current custom_slugs_in_use counter; present when reason=cap_reached."
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "Unauthenticated."
          }
        }
      }
    },
    "/api/codes/import": {
      "post": {
        "tags": ["Codes"],
        "summary": "Bulk-import codes",
        "description": "Accepts up to 500 codes in one request.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["codes"],
                "properties": {
                  "codes": {
                    "type": "array",
                    "maxItems": 500,
                    "items": {
                      "$ref": "#/components/schemas/CodeCreate"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Imported."
          }
        }
      }
    },
    "/api/codes/bulk": {
      "post": {
        "tags": ["Codes"],
        "summary": "Bulk operations",
        "description": "One of: add_tag / remove_tag / set_tags / set_group / move / archive / restore. Max 500 code IDs per call.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["action", "code_ids"],
                "properties": {
                  "action": {
                    "type": "string",
                    "enum": [
                      "add_tag",
                      "remove_tag",
                      "set_tags",
                      "set_group",
                      "move",
                      "archive",
                      "restore"
                    ]
                  },
                  "code_ids": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "maxItems": 500
                  },
                  "tag": {
                    "type": "string"
                  },
                  "tags": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  },
                  "group_id": {
                    "type": "string",
                    "nullable": true
                  },
                  "team_id": {
                    "type": "string",
                    "nullable": true
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Applied."
          }
        }
      }
    },
    "/api/codes/top": {
      "get": {
        "tags": ["Analytics"],
        "summary": "Top codes by scans",
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 10,
              "maximum": 50
            }
          },
          {
            "name": "days",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 30
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Top-N list."
          }
        }
      }
    },
    "/api/codes/stale": {
      "get": {
        "tags": ["Analytics"],
        "summary": "Codes not scanned recently",
        "parameters": [
          {
            "name": "days",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 90
            }
          },
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 100
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Stale codes."
          }
        }
      }
    },
    "/api/codes/tags": {
      "get": {
        "tags": ["Codes"],
        "summary": "List all tags in scope",
        "responses": {
          "200": {
            "description": "Distinct tag list with code counts."
          }
        }
      }
    },
    "/api/codes/{id}/analytics": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "tags": ["Analytics"],
        "summary": "Scan time-series",
        "parameters": [
          {
            "name": "range",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": ["7d", "30d", "90d", "1y"]
            }
          },
          {
            "name": "granularity",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": ["day", "hour"]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Time-series."
          }
        }
      }
    },
    "/api/codes/{id}/analytics.csv": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "tags": ["Analytics"],
        "summary": "Scan time-series CSV export",
        "responses": {
          "200": {
            "description": "CSV file.",
            "content": {
              "text/csv": {}
            }
          }
        }
      }
    },
    "/api/codes/{id}/heatmaps": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "tags": ["Analytics"],
        "summary": "Calendar + hour\u00d7dow + geo heatmap data",
        "responses": {
          "200": {
            "description": "Heatmap data."
          }
        }
      }
    },
    "/api/codes/{id}/lifetime": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "tags": ["Analytics"],
        "summary": "Campaign lifetime + ROI",
        "responses": {
          "200": {
            "description": "Lifetime + ROI (if print cost recorded)."
          }
        }
      },
      "put": {
        "tags": ["Analytics"],
        "summary": "Set print cost + count for ROI",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "print_cost_cents": {
                    "type": "integer",
                    "nullable": true,
                    "minimum": 0
                  },
                  "print_count": {
                    "type": "integer",
                    "nullable": true,
                    "minimum": 0
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Saved."
          }
        }
      }
    },
    "/api/codes/{id}/deep-stats": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "tags": ["Analytics"],
        "summary": "Per-code deep analytics",
        "description": "Narrative + windows (7/30/90d) + projection + anomaly + velocity + bullet vs target + YoY + peer benchmark + day-of-life + top country.",
        "responses": {
          "200": {
            "description": "Deep analytics."
          }
        }
      }
    },
    "/api/user/scope-stats": {
      "get": {
        "tags": ["Analytics", "User"],
        "summary": "Scope-level rollup",
        "description": "Full /stats/ page rollup: deltas, pareto, projection, anomalies, velocity, day-of-life, horizon, bump, cohort, heatmap, bullet, yoy, countries, streak, personal bests, milestones, scope-bullet, time-to-first-scan, top tags, concentration, new-vs-established. Cache-backed with 24h TTL.",
        "responses": {
          "200": {
            "description": "Rollup (with cache.hit flag)."
          }
        }
      }
    },
    "/api/user/workspace-stats": {
      "get": {
        "tags": ["User", "Analytics"],
        "summary": "Personal workspace rollup (without team scope)",
        "responses": {
          "200": {
            "description": "Workspace rollup."
          }
        }
      }
    },
    "/api/user/scan-usage": {
      "get": {
        "tags": ["User"],
        "summary": "Monthly scan-cap progress",
        "responses": {
          "200": {
            "description": "{ month, count, cap, over }"
          }
        }
      }
    },
    "/api/user/weather-overlay": {
      "get": {
        "tags": ["Analytics"],
        "summary": "Top-3 country weather \u00d7 scan correlation",
        "description": "Daily temperature + precipitation from open-meteo for the scope's top-3 scan countries. Pearson correlation coefficient with scan counts. 24h KV-cached.",
        "parameters": [
          {
            "name": "days",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 30,
              "minimum": 7,
              "maximum": 90
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Per-country series + correlations."
          }
        }
      }
    },
    "/api/user/export": {
      "get": {
        "tags": ["User"],
        "summary": "Full data export (ZIP of codes.csv + scans.csv + README.txt)",
        "responses": {
          "200": {
            "description": "ZIP file.",
            "content": {
              "application/zip": {}
            }
          }
        }
      }
    },
    "/api/user/me": {
      "delete": {
        "tags": ["User"],
        "summary": "Delete account (enters 30-day hard-delete hold). Cookie-authenticated only \u2014 an API-key request returns 403 key_not_permitted.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["confirm"],
                "properties": {
                  "confirm": {
                    "type": "string",
                    "enum": ["DELETE"]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Deletion scheduled."
          }
        }
      }
    },
    "/api/user/workspace-label": {
      "patch": {
        "tags": ["User"],
        "summary": "Rename personal workspace",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["label"],
                "properties": {
                  "label": {
                    "type": "string",
                    "maxLength": 120
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Renamed."
          }
        }
      }
    },
    "/api/user/current-team": {
      "patch": {
        "tags": ["User", "Teams"],
        "summary": "Switch active team. Cookie-authenticated only \u2014 an API-key request returns 403 key_not_permitted.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "team_id": {
                    "type": "string",
                    "nullable": true
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Switched."
          }
        }
      }
    },
    "/api/groups": {
      "get": {
        "tags": ["Groups"],
        "summary": "List groups in scope",
        "responses": {
          "200": {
            "description": "Groups."
          }
        }
      },
      "post": {
        "tags": ["Groups"],
        "summary": "Create a group",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/GroupCreate"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Created."
          }
        }
      }
    },
    "/api/groups/{id}": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "tags": ["Groups"],
        "summary": "Get a group",
        "responses": {
          "200": {
            "description": "The group."
          }
        }
      },
      "patch": {
        "tags": ["Groups"],
        "summary": "Update a group",
        "responses": {
          "200": {
            "description": "Updated."
          }
        }
      },
      "delete": {
        "tags": ["Groups"],
        "summary": "Delete a group (member codes become ungrouped)",
        "responses": {
          "200": {
            "description": "Deleted."
          }
        }
      }
    },
    "/api/groups/{id}/stats": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "tags": ["Groups", "Analytics"],
        "summary": "Group-scoped rollup (same shape as /api/user/scope-stats filtered to group)",
        "responses": {
          "200": {
            "description": "Group rollup."
          }
        }
      }
    },
    "/api/groups/{id}/heatmaps": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "tags": ["Groups", "Analytics"],
        "summary": "Group-scoped calendar + hour\u00d7dow + geo heatmap data",
        "responses": {
          "200": {
            "description": "Heatmap data aggregated across every code in the group."
          }
        }
      }
    },
    "/api/teams": {
      "get": {
        "tags": ["Teams"],
        "summary": "List teams the caller belongs to",
        "responses": {
          "200": {
            "description": "Teams."
          }
        }
      },
      "post": {
        "tags": ["Teams"],
        "summary": "Create a team",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["name"],
                "properties": {
                  "name": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Created."
          }
        }
      }
    },
    "/api/teams/{id}": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "tags": ["Teams"],
        "summary": "Get a team",
        "responses": {
          "200": {
            "description": "The team."
          }
        }
      },
      "patch": {
        "tags": ["Teams"],
        "summary": "Rename / update settings",
        "responses": {
          "200": {
            "description": "Updated."
          }
        }
      },
      "delete": {
        "tags": ["Teams"],
        "summary": "Delete a team (owner only)",
        "responses": {
          "200": {
            "description": "Deleted."
          }
        }
      }
    },
    "/api/teams/{id}/stats": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "tags": ["Teams", "Analytics"],
        "summary": "Team-scoped rollup",
        "responses": {
          "200": {
            "description": "Team rollup."
          }
        }
      }
    },
    "/api/teams/{id}/heatmaps": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "tags": ["Teams", "Analytics"],
        "summary": "Team-scoped calendar + hour\u00d7dow + geo heatmap data",
        "responses": {
          "200": {
            "description": "Heatmap data aggregated across every code in the team."
          }
        }
      }
    },
    "/api/teams/{id}/activity": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "tags": ["Teams"],
        "summary": "Team audit log + leaderboard",
        "responses": {
          "200": {
            "description": "Activity feed + per-member leaderboard."
          }
        }
      }
    },
    "/api/teams/{id}/members": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "tags": ["Teams"],
        "summary": "List team members",
        "responses": {
          "200": {
            "description": "Members."
          }
        }
      }
    },
    "/api/teams/{id}/members/{user_id}": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        },
        {
          "name": "user_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "patch": {
        "tags": ["Teams"],
        "summary": "Change a member's role",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["role"],
                "properties": {
                  "role": {
                    "type": "string",
                    "enum": ["member", "admin", "owner"]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated."
          }
        }
      },
      "delete": {
        "tags": ["Teams"],
        "summary": "Remove a member",
        "responses": {
          "200": {
            "description": "Removed."
          }
        }
      }
    },
    "/api/teams/{id}/invites": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "tags": ["Teams"],
        "summary": "List pending invites",
        "responses": {
          "200": {
            "description": "Invites."
          }
        }
      },
      "post": {
        "tags": ["Teams"],
        "summary": "Invite a user",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["email", "role"],
                "properties": {
                  "email": {
                    "type": "string",
                    "format": "email"
                  },
                  "role": {
                    "type": "string",
                    "enum": ["member", "admin"]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Invite sent."
          }
        }
      }
    },
    "/api/teams/{id}/invites/{invite_id}": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        },
        {
          "name": "invite_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "delete": {
        "tags": ["Teams"],
        "summary": "Revoke a pending invite",
        "responses": {
          "200": {
            "description": "Revoked."
          }
        }
      }
    },
    "/api/webhooks": {
      "get": {
        "tags": ["Webhooks"],
        "summary": "List webhooks",
        "responses": {
          "200": {
            "description": "Webhooks + available event types."
          }
        }
      },
      "post": {
        "tags": ["Webhooks"],
        "summary": "Register a webhook",
        "description": "Signing secret is returned ONCE on creation.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["url", "events"],
                "properties": {
                  "url": {
                    "type": "string",
                    "format": "uri"
                  },
                  "events": {
                    "type": "array",
                    "items": {
                      "type": "string",
                      "enum": [
                        "code.created",
                        "code.updated",
                        "code.deleted",
                        "anomaly.surge",
                        "anomaly.drop",
                        "plan.changed"
                      ]
                    }
                  },
                  "description": {
                    "type": "string",
                    "maxLength": 120
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Created. Response includes the signing secret."
          }
        }
      }
    },
    "/api/webhooks/{id}": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "delete": {
        "tags": ["Webhooks"],
        "summary": "Delete a webhook",
        "responses": {
          "200": {
            "description": "Deleted."
          }
        }
      }
    },
    "/api/stats/recent-scans": {
      "get": {
        "tags": ["Analytics"],
        "summary": "Recent scan events (daily, or hourly on Team+)",
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 20,
              "maximum": 100
            }
          },
          {
            "name": "hourly",
            "in": "query",
            "schema": {
              "type": "boolean"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Events."
          }
        }
      }
    },
    "/api/stats/tag-rollups": {
      "get": {
        "tags": ["Analytics"],
        "summary": "Scans grouped by tag",
        "parameters": [
          {
            "name": "days",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 30
            }
          },
          {
            "name": "top",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 20
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Tag rollups."
          }
        }
      }
    },
    "/api/audit": {
      "get": {
        "summary": "Scoped audit-log feed for current user",
        "description": "Returns recent audit_log rows for the current scope (personal or team). Used by the federated /account/audit/ surface on abundera.ai (ADR 081).",
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 100
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Audit entries for current scope",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "entries": {
                      "type": "array",
                      "items": {
                        "type": "object"
                      }
                    },
                    "scope": {
                      "type": "string"
                    },
                    "limit": {
                      "type": "integer"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "Unauthenticated"
          },
          "500": {
            "description": "Server error"
          }
        }
      }
    },
    "/api/push/vapid-key": {
      "get": {
        "summary": "Return the VAPID public key used for Web Push subscriptions",
        "responses": {
          "200": {
            "description": "VAPID public key"
          }
        }
      }
    },
    "/api/push/subscribe": {
      "post": {
        "summary": "Register a Web Push subscription for the current user",
        "responses": {
          "200": {
            "description": "Subscription stored"
          },
          "400": {
            "description": "Invalid subscription"
          },
          "401": {
            "description": "Unauthenticated"
          }
        }
      },
      "delete": {
        "summary": "Remove a Web Push subscription for the current user",
        "responses": {
          "200": {
            "description": "Subscription removed"
          },
          "400": {
            "description": "Endpoint required"
          },
          "401": {
            "description": "Unauthenticated"
          }
        }
      }
    },
    "/api/org": {
      "get": {
        "summary": "Get white-label org config",
        "description": "Returns the org config for the authenticated Enterprise user. Returns 404 for non-Enterprise accounts.",
        "tags": ["Org"],
        "security": [
          {
            "bearerAuth": []
          },
          {
            "cookieAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "Org config"
          },
          "404": {
            "description": "No org on this account"
          },
          "401": {
            "description": "Unauthenticated"
          }
        }
      },
      "patch": {
        "summary": "Update org branding (self-serve)",
        "description": "Update name, support_email, brand_color, logo_url. Only the org owner may call this. Domain fields require admin API.",
        "tags": ["Org"],
        "security": [
          {
            "cookieAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string"
                  },
                  "support_email": {
                    "type": "string",
                    "format": "email"
                  },
                  "brand_color": {
                    "type": "string",
                    "pattern": "^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$"
                  },
                  "logo_url": {
                    "type": "string",
                    "format": "uri"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated org config"
          },
          "400": {
            "description": "Validation error"
          },
          "403": {
            "description": "Not org owner"
          },
          "404": {
            "description": "No org on this account"
          },
          "401": {
            "description": "Unauthenticated"
          }
        }
      }
    },
    "/api/org/brand": {
      "get": {
        "summary": "Public org brand config",
        "description": "Returns brand name, accent color, and logo URL for a given org_id. Public endpoint \u2014 no auth required. Used by the login page to render with the org's branding.",
        "tags": ["Org"],
        "parameters": [
          {
            "name": "id",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Brand config"
          },
          "404": {
            "description": "Org not found"
          }
        }
      }
    },
    "/api/org/verify-domains": {
      "get": {
        "summary": "Get domain verification status",
        "description": "Returns current verification status and required CNAME records for the org's custom domains.",
        "tags": ["Org"],
        "security": [
          {
            "cookieAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "Domain verification status"
          },
          "404": {
            "description": "No org on this account"
          },
          "401": {
            "description": "Unauthenticated"
          }
        }
      },
      "post": {
        "summary": "Verify a custom domain and provision TLS",
        "description": "Checks CNAME resolution and provisions a Cloudflare for SaaS Custom Hostname. Idempotent \u2014 safe to re-call.",
        "tags": ["Org"],
        "security": [
          {
            "cookieAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["type"],
                "properties": {
                  "type": {
                    "type": "string",
                    "enum": ["short_url", "dashboard"]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Verification result (verified: true/false)"
          },
          "400": {
            "description": "Domain not set or invalid type"
          },
          "403": {
            "description": "Not org owner"
          },
          "404": {
            "description": "No org on this account"
          },
          "401": {
            "description": "Unauthenticated"
          }
        }
      }
    },
    "/api/org/logo": {
      "get": {
        "summary": "Serve org logo from R2",
        "description": "Public endpoint \u2014 serves the org logo file from R2. No auth required.",
        "operationId": "getOrgLogo",
        "tags": ["Org"],
        "parameters": [
          {
            "name": "key",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "R2 object key (org-logos/<org_id>/logo.<ext>)"
          }
        ],
        "responses": {
          "200": {
            "description": "Logo file",
            "content": {
              "image/*": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          },
          "404": {
            "description": "Not found"
          }
        }
      },
      "put": {
        "summary": "Upload org logo to R2",
        "description": "Owner-only. Accepts base64-encoded image (PNG/JPG/SVG/WebP, max 1MB). Returns hosted logo_url.",
        "operationId": "putOrgLogo",
        "tags": ["Org"],
        "security": [
          {
            "cookieAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["mime_type", "data"],
                "properties": {
                  "filename": {
                    "type": "string"
                  },
                  "mime_type": {
                    "type": "string",
                    "enum": [
                      "image/png",
                      "image/jpeg",
                      "image/svg+xml",
                      "image/webp"
                    ]
                  },
                  "data": {
                    "type": "string",
                    "description": "Base64-encoded image data"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Logo uploaded",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "logo_url": {
                      "type": "string",
                      "format": "uri"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Invalid input"
          },
          "413": {
            "description": "File too large"
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "abnd_qrpro_<secret>",
        "description": "API keys (Business, Team, Agency plans). Create via `/account/keys`. Keys are SHA-256 hashed at rest."
      },
      "webhookSignature": {
        "type": "apiKey",
        "in": "header",
        "name": "X-Abundera-Signature",
        "description": "Stripe-compatible HMAC-SHA256 signature sent on OUTBOUND webhook deliveries. Format: `t=<unix_ts>,v1=<hex>`. Verify by recomputing HMAC-SHA256 of `\"{t}.{raw_body}\"` with your registered secret and comparing in constant time."
      }
    },
    "schemas": {
      "Pagination": {
        "type": "object",
        "properties": {
          "page": {
            "type": "integer"
          },
          "page_size": {
            "type": "integer"
          },
          "total": {
            "type": "integer"
          },
          "total_pages": {
            "type": "integer"
          },
          "has_more": {
            "type": "boolean"
          }
        }
      },
      "Scope": {
        "type": "object",
        "properties": {
          "type": {
            "type": "string",
            "enum": ["user", "team"]
          },
          "team_id": {
            "type": "string",
            "nullable": true
          },
          "role": {
            "type": "string",
            "nullable": true
          },
          "workspace_label": {
            "type": "string",
            "nullable": true
          }
        }
      },
      "Code": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string"
          },
          "shortcode": {
            "type": "string"
          },
          "short_url": {
            "type": "string",
            "format": "uri"
          },
          "url": {
            "type": "string",
            "format": "uri"
          },
          "label": {
            "type": "string",
            "nullable": true
          },
          "tags": {
            "type": "string",
            "nullable": true,
            "description": "Comma-separated."
          },
          "status": {
            "type": "string",
            "enum": ["active", "paused", "grace", "expired"]
          },
          "group_id": {
            "type": "string",
            "nullable": true
          },
          "team_id": {
            "type": "string",
            "nullable": true
          },
          "qr_type": {
            "type": "string"
          },
          "qr_data": {
            "type": "string",
            "nullable": true
          },
          "style_json": {
            "type": "string",
            "nullable": true
          },
          "logo_key": {
            "type": "string",
            "nullable": true
          },
          "logo_svg": {
            "type": "string",
            "nullable": true
          },
          "frame_style": {
            "type": "string",
            "nullable": true
          },
          "frame_text": {
            "type": "string",
            "nullable": true
          },
          "export_format": {
            "type": "string",
            "nullable": true
          },
          "created_at": {
            "type": "integer",
            "description": "Unix seconds."
          },
          "updated_at": {
            "type": "integer"
          },
          "grace_until": {
            "type": "integer",
            "nullable": true
          },
          "print_cost_cents": {
            "type": "integer",
            "nullable": true
          },
          "print_count": {
            "type": "integer",
            "nullable": true
          },
          "lifetime_scans": {
            "type": "integer"
          },
          "last_scan_day": {
            "type": "string",
            "nullable": true,
            "format": "date"
          },
          "first_scan_day": {
            "type": "string",
            "nullable": true,
            "format": "date"
          },
          "distinct_countries": {
            "type": "integer"
          }
        }
      },
      "CodeCreate": {
        "type": "object",
        "required": ["url"],
        "properties": {
          "url": {
            "type": "string",
            "format": "uri"
          },
          "label": {
            "type": "string",
            "maxLength": 120
          },
          "tags": {
            "type": "string",
            "description": "Comma-separated, max 240 chars."
          },
          "qr_type": {
            "type": "string",
            "default": "url"
          },
          "qr_data": {
            "type": "string"
          },
          "style_json": {
            "type": "string"
          },
          "logo_key": {
            "type": "string"
          },
          "logo_svg": {
            "type": "string"
          },
          "frame_style": {
            "type": "string"
          },
          "frame_text": {
            "type": "string"
          },
          "export_format": {
            "type": "string"
          }
        }
      },
      "CodePatch": {
        "type": "object",
        "properties": {
          "url": {
            "type": "string"
          },
          "label": {
            "type": "string"
          },
          "tags": {
            "type": "string"
          },
          "status": {
            "type": "string",
            "enum": ["active", "paused"]
          },
          "group_id": {
            "type": "string",
            "nullable": true
          },
          "team_id": {
            "type": "string",
            "nullable": true,
            "description": "Move to team (UUID) or personal (null). Owner-only."
          },
          "qr_type": {
            "type": "string"
          },
          "qr_data": {
            "type": "string"
          },
          "style_json": {
            "type": "string"
          },
          "logo_key": {
            "type": "string"
          },
          "logo_svg": {
            "type": "string"
          },
          "frame_style": {
            "type": "string"
          },
          "frame_text": {
            "type": "string"
          },
          "export_format": {
            "type": "string"
          }
        }
      },
      "GroupCreate": {
        "type": "object",
        "required": ["name"],
        "properties": {
          "name": {
            "type": "string"
          },
          "description": {
            "type": "string"
          },
          "color": {
            "type": "string",
            "pattern": "^#[0-9a-fA-F]{6}$"
          }
        }
      }
    }
  }
}
