{
  "openapi": "3.1.0",
  "info": {
    "title": "Figgshield API",
    "version": "2.0.0",
    "summary": "MCP tools plus a REST API for generating video with Kling and images with Nano Banana 2, on one credit balance.",
    "description": "Figgshield is an MCP server and CLI tool. You add it to **Claude** (as a connector) or the **Claude Code CLI** and generate **video with Kling** and **images with Nano Banana 2** by prompting. Credits are metered and billed via an annual subscription; the web app is for account management only.\n\n**MCP.** The server lives at `POST/GET/DELETE https://api.figgshield.ai/mcp` (Streamable HTTP, MCP spec `2025-11-25`). It exposes five tools — `generate_image`, `generate_video`, `get_generation`, `list_library`, `get_usage` — documented at https://figgshield.ai/documentation/mcp/. Authenticate with OAuth 2.1 (browser sign-in, PKCE) or an API key. OpenAPI cannot express the JSON-RPC tool surface; this file documents the endpoint, the OAuth discovery, and the equivalent REST paths.\n\n**REST (non-MCP).** `POST /api/generations/create/` returns a job immediately with `status: \"queued\"`; poll `GET /api/generations/{uuid}/` (no more often than every 2 seconds) until `status` is `succeeded` or `failed`, then download `output_url`. Failed generations refund their credits automatically.\n\n**Authentication.** Web sign-in is SSO only (Google, Apple, GitHub). Programmatic access uses OAuth (the MCP connector) or an API key created in the app under Account → API keys, sent as `Authorization: Bearer figg_...` (or `X-API-Key: figg_...`).\n\n**Conventions.** JSON in and out. Lists return `{\"pagination\": {...}, \"data\": [...]}`. Errors return `{\"error\": \"<machine_code>\", \"detail\": \"<human sentence>\"}` — match on `error`, never on `detail`.\n\n**Billing.** Four paid plans (Lite, Starter, Pro, Studio), billed annually: the monthly price is a display sticker and the charge is twelve times the sticker, once a year. Credits are granted monthly and can be topped up (minimum $50) manually or automatically.\n\n**Affiliates.** 30% recurring commission for the lifetime of every referred customer, paid via Stripe Connect — `/api/affiliate/...`.\n\nHuman documentation: https://figgshield.ai/documentation/ (every page also available as Markdown at the same URL + `.md`; index at https://figgshield.ai/llms.txt).",
    "contact": {
      "name": "Figgshield documentation",
      "url": "https://figgshield.ai/documentation/"
    },
    "termsOfService": "https://figgshield.ai/terms/"
  },
  "servers": [
    {
      "url": "https://api.figgshield.ai",
      "description": "Production"
    }
  ],
  "security": [{ "bearerAuth": [] }, { "apiKeyAuth": [] }],
  "tags": [
    {
      "name": "MCP",
      "description": "The MCP server endpoint and its OAuth discovery. Five tools (generate_image, generate_video, get_generation, list_library, get_usage) drive generation from Claude and the Claude Code CLI. Docs: https://figgshield.ai/documentation/mcp/"
    },
    {
      "name": "Generations",
      "description": "The non-MCP REST path: create Kling video / Nano Banana 2 image jobs, poll them, list and delete them. Docs: https://figgshield.ai/documentation/generations/"
    },
    {
      "name": "Models",
      "description": "The two live models (Kling, Nano Banana 2): capabilities, live status and per-generation credit costs. Docs: https://figgshield.ai/documentation/models/"
    },
    {
      "name": "API keys",
      "description": "Create, list and revoke API keys. Keys authenticate the CLI header and the REST API. Docs: https://figgshield.ai/documentation/authentication/"
    },
    {
      "name": "Usage",
      "description": "Current-period usage summary and the append-only credit ledger. Docs: https://figgshield.ai/documentation/usage/"
    },
    {
      "name": "Billing",
      "description": "Plans (annual-only), checkout, billing status, manual and automatic top-ups. Docs: https://figgshield.ai/documentation/billing/"
    },
    {
      "name": "Affiliate",
      "description": "Affiliate enrolment, status and Stripe Connect onboarding. 30% recurring commission, lifetime, paid via Stripe Connect."
    },
    {
      "name": "Authentication (session)",
      "description": "SSO sign-in (Google, Apple, GitHub) sets an HttpOnly session cookie for the account screens. There is no email/password or MFA. API keys are NOT accepted on these endpoints."
    },
    {
      "name": "Meta",
      "description": "Service health."
    },
    {
      "name": "Admin",
      "description": "Staff-only platform metrics. Requires a staff account; non-staff callers receive 403 admin_required. Docs: https://figgshield.ai/documentation/admin/"
    }
  ],
  "paths": {
    "/mcp": {
      "post": {
        "tags": ["MCP"],
        "operationId": "mcpEndpoint",
        "summary": "MCP server (Streamable HTTP)",
        "security": [{ "oauth2": ["figgshield:read", "figgshield:write"] }, { "bearerAuth": [] }],
        "description": "The Model Context Protocol endpoint (spec `2025-11-25`, Streamable HTTP). This is a JSON-RPC surface, not a plain REST resource — OpenAPI cannot describe its methods, so use the [MCP tools reference](https://figgshield.ai/documentation/mcp/). Supports `POST` (requests), `GET` (server-sent stream) and `DELETE` (end session). Tools: `generate_image` (Nano Banana 2, 8 credits), `generate_video` (Kling, 35/70 credits), `get_generation`, `list_library`, `get_usage`. Authenticate with an OAuth 2.1 access token (`aud=https://api.figgshield.ai/mcp`) or an `Authorization: Bearer figg_...` API key. An unauthenticated request returns `401` with a `WWW-Authenticate: Bearer resource_metadata=\"...\"` header pointing at the protected-resource metadata.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "type": "object", "description": "A JSON-RPC 2.0 message (initialize, tools/list, tools/call, ...)." },
              "example": {
                "jsonrpc": "2.0",
                "id": 1,
                "method": "tools/call",
                "params": {
                  "name": "generate_video",
                  "arguments": { "prompt": "A quiet harbour at dawn", "duration": 5, "resolution": "720p" }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "A JSON-RPC response (or an SSE stream for streaming methods).",
            "content": {
              "application/json": {
                "schema": { "type": "object" },
                "example": {
                  "jsonrpc": "2.0",
                  "id": 1,
                  "result": {
                    "content": [
                      { "type": "text", "text": "Generated with Kling. generation_uuid=0b8f6a2e-… credits_charged=35" }
                    ]
                  }
                }
              }
            }
          },
          "401": {
            "description": "No or invalid credential. The `WWW-Authenticate` header points at `/.well-known/oauth-protected-resource`.",
            "headers": {
              "WWW-Authenticate": {
                "schema": { "type": "string" },
                "description": "`Bearer resource_metadata=\"https://api.figgshield.ai/.well-known/oauth-protected-resource\"`"
              }
            }
          }
        }
      }
    },
    "/.well-known/oauth-protected-resource": {
      "get": {
        "tags": ["MCP"],
        "operationId": "protectedResourceMetadata",
        "summary": "Protected resource metadata (RFC 9728)",
        "security": [],
        "description": "OAuth 2.0 Protected Resource Metadata for the `/mcp` resource. Clients read this after a 401 challenge to discover the authorization server.",
        "responses": {
          "200": {
            "description": "Resource metadata.",
            "content": {
              "application/json": {
                "example": {
                  "resource": "https://api.figgshield.ai/mcp",
                  "authorization_servers": ["https://api.figgshield.ai"],
                  "scopes_supported": ["figgshield:read", "figgshield:write"],
                  "bearer_methods_supported": ["header"]
                }
              }
            }
          }
        }
      }
    },
    "/.well-known/oauth-authorization-server": {
      "get": {
        "tags": ["MCP"],
        "operationId": "authorizationServerMetadata",
        "summary": "Authorization server metadata (RFC 8414)",
        "security": [],
        "description": "OAuth 2.1 authorization server metadata: authorize/token endpoints, JWKS, supported scopes and PKCE. PKCE (S256) is required; Google, Apple and GitHub are the upstream identity providers. Dynamic Client Registration (RFC 7591) is supported so Claude can register itself.",
        "responses": {
          "200": {
            "description": "Authorization server metadata.",
            "content": {
              "application/json": {
                "example": {
                  "issuer": "https://api.figgshield.ai",
                  "authorization_endpoint": "https://api.figgshield.ai/authorize",
                  "token_endpoint": "https://api.figgshield.ai/token",
                  "registration_endpoint": "https://api.figgshield.ai/register",
                  "jwks_uri": "https://api.figgshield.ai/jwks.json",
                  "scopes_supported": ["figgshield:read", "figgshield:write"],
                  "response_types_supported": ["code"],
                  "grant_types_supported": ["authorization_code", "refresh_token"],
                  "code_challenge_methods_supported": ["S256"]
                }
              }
            }
          }
        }
      }
    },
    "/api/generations/create/": {
      "post": {
        "tags": ["Generations"],
        "operationId": "createGeneration",
        "summary": "Create a generation (REST)",
        "description": "Submits a Kling video or Nano Banana 2 image job and returns immediately with `status: \"queued\"`. The credit cost is checked before dispatch: if the balance cannot cover it the request fails with `402 insufficient_credits` and nothing is charged. With automatic top-ups enabled, the top-up is attempted first. If the job later fails, the charged credits are refunded automatically. Poll `GET /api/generations/{uuid}/` to follow progress. (In Claude or the Claude Code CLI, use the `generate_video` / `generate_image` MCP tools instead.)",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/GenerationCreateRequest" },
              "examples": {
                "video": {
                  "summary": "Kling video (35 credits)",
                  "value": {
                    "model": "kling-video",
                    "prompt": "A slow dolly shot across a quiet harbour at dawn",
                    "params": { "duration": 5, "resolution": "720p", "audio": false }
                  }
                },
                "image": {
                  "summary": "Nano Banana 2 image (8 credits)",
                  "value": {
                    "model": "nano-banana-2",
                    "prompt": "A paper boat on a still pond, top-down",
                    "params": { "resolution": "2K" }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Job accepted and queued.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Generation" },
                "example": {
                  "uuid": "0b8f6a2e-4c1d-4f7a-9b2e-6d3c8a1f5e90",
                  "model": "kling-video",
                  "model_name": "Kling",
                  "kind": "video",
                  "prompt": "A slow dolly shot across a quiet harbour at dawn",
                  "params": { "duration": 5, "resolution": "720p", "audio": false, "aspect_ratio": "16:9" },
                  "status": "queued",
                  "output_url": null,
                  "thumbnail_url": null,
                  "credits_charged": 35,
                  "error_message": null,
                  "created_at": "2026-07-06T09:00:00Z",
                  "completed_at": null,
                  "duration_ms": null,
                  "is_watermarked": false
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/ValidationError" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "402": {
            "description": "Not enough credits (`insufficient_credits` — including a failed automatic top-up payment) or no active subscription (`subscription_required`).",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Error" },
                "examples": {
                  "insufficient_credits": {
                    "value": { "error": "insufficient_credits", "detail": "This generation costs 35 credits but your balance is 12." }
                  },
                  "subscription_required": {
                    "value": { "error": "subscription_required", "detail": "An active subscription is required to create generations." }
                  }
                }
              }
            }
          },
          "409": {
            "description": "As many jobs already in flight as the plan allows; wait for one to finish.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Error" },
                "example": { "error": "parallel_limit_reached", "detail": "Your plan allows 5 parallel generations and 5 are already running." }
              }
            }
          },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "503": {
            "description": "The model is offline or its provider is not responding. Nothing was charged.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Error" },
                "example": { "error": "model_unavailable", "detail": "Kling is currently offline. Retry later." }
              }
            }
          }
        }
      }
    },
    "/api/generations/": {
      "get": {
        "tags": ["Generations"],
        "operationId": "listGenerations",
        "summary": "List generations",
        "description": "The authenticated account's generations, newest first (`-created_at`), in the standard pagination envelope.",
        "parameters": [{ "$ref": "#/components/parameters/Page" }],
        "responses": {
          "200": {
            "description": "One page of generations.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["pagination", "data"],
                  "properties": {
                    "pagination": { "$ref": "#/components/schemas/Pagination" },
                    "data": { "type": "array", "items": { "$ref": "#/components/schemas/Generation" } }
                  }
                },
                "example": {
                  "pagination": {
                    "next": "https://api.figgshield.ai/api/generations/?page=2",
                    "previous": null,
                    "current_page": 1,
                    "total_pages": 4,
                    "total_items": 74
                  },
                  "data": [
                    {
                      "uuid": "0b8f6a2e-4c1d-4f7a-9b2e-6d3c8a1f5e90",
                      "model": "kling-video",
                      "model_name": "Kling",
                      "kind": "video",
                      "prompt": "A slow dolly shot across a quiet harbour at dawn",
                      "params": { "duration": 5, "resolution": "720p", "audio": false, "aspect_ratio": "16:9" },
                      "status": "succeeded",
                      "output_url": "https://cdn.figgshield.ai/outputs/0b8f6a2e.mp4",
                      "thumbnail_url": "https://cdn.figgshield.ai/thumbs/0b8f6a2e.jpg",
                      "credits_charged": 35,
                      "error_message": null,
                      "created_at": "2026-07-06T09:00:00Z",
                      "completed_at": "2026-07-06T09:00:48Z",
                      "duration_ms": 48000,
                      "is_watermarked": false
                    }
                  ]
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/generations/{uuid}/": {
      "parameters": [{ "$ref": "#/components/parameters/GenerationUuid" }],
      "get": {
        "tags": ["Generations"],
        "operationId": "getGeneration",
        "summary": "Get a generation (polling endpoint)",
        "description": "Returns the generation and refreshes its provider status on read — **this is the polling endpoint**. The backend throttles provider polls to at most one per 2 seconds per job, so poll at a 2-second interval or slower. Stop polling once `status` is `succeeded` (download `output_url`) or `failed` (`error_message` set, credits refunded automatically).",
        "responses": {
          "200": {
            "description": "Current state of the generation.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Generation" },
                "example": {
                  "uuid": "0b8f6a2e-4c1d-4f7a-9b2e-6d3c8a1f5e90",
                  "model": "kling-video",
                  "model_name": "Kling",
                  "kind": "video",
                  "prompt": "A slow dolly shot across a quiet harbour at dawn",
                  "params": { "duration": 5, "resolution": "720p", "audio": false, "aspect_ratio": "16:9" },
                  "status": "succeeded",
                  "output_url": "https://cdn.figgshield.ai/outputs/0b8f6a2e.mp4",
                  "thumbnail_url": "https://cdn.figgshield.ai/thumbs/0b8f6a2e.jpg",
                  "credits_charged": 35,
                  "error_message": null,
                  "created_at": "2026-07-06T09:00:00Z",
                  "completed_at": "2026-07-06T09:00:48Z",
                  "duration_ms": 48000,
                  "is_watermarked": false
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      },
      "delete": {
        "tags": ["Generations"],
        "operationId": "deleteGeneration",
        "summary": "Delete a generation",
        "description": "Soft-deletes the generation: it disappears from lists. Deleting does not refund credits — only job failure does.",
        "responses": {
          "204": { "description": "Deleted." },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/models/": {
      "get": {
        "tags": ["Models"],
        "operationId": "listModels",
        "summary": "List models (public)",
        "security": [],
        "description": "The two live models — public, no authentication required. `status` comes from live health checks (`online` / `degraded` / `offline`). Use `slug` (`kling-video` or `nano-banana-2`) as the `model` value when creating a generation, `credits_base` for the base cost, and `params_schema` for accepted values.",
        "parameters": [{ "$ref": "#/components/parameters/Page" }],
        "responses": {
          "200": {
            "description": "The model library.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["pagination", "data"],
                  "properties": {
                    "pagination": { "$ref": "#/components/schemas/Pagination" },
                    "data": { "type": "array", "items": { "$ref": "#/components/schemas/Model" } }
                  }
                },
                "example": {
                  "pagination": { "next": null, "previous": null, "current_page": 1, "total_pages": 1, "total_items": 2 },
                  "data": [
                    {
                      "uuid": "3c2b1a09-8f7e-4d6c-5b4a-392817065e4d",
                      "slug": "kling-video",
                      "name": "Kling",
                      "provider": "fal",
                      "kind": "video",
                      "description": "Cinematic motion in 5 or 10-second clips, with optional audio.",
                      "credits_base": 35,
                      "status": "online",
                      "sort_order": 10,
                      "params_schema": {
                        "durations": [5, 10],
                        "resolutions": ["720p", "1080p"],
                        "audio": true,
                        "aspect_ratios": ["16:9", "9:16"]
                      }
                    },
                    {
                      "uuid": "5e4d3c2b-1a09-4f8e-7d6c-5b4a39281706",
                      "slug": "nano-banana-2",
                      "name": "Nano Banana 2",
                      "provider": "google",
                      "kind": "image",
                      "description": "Detailed, editable stills at 1K, 2K or 4K.",
                      "credits_base": 8,
                      "status": "online",
                      "sort_order": 20,
                      "params_schema": {
                        "resolutions": ["1K", "2K", "4K"],
                        "aspect_ratios": ["16:9", "1:1", "9:16"]
                      }
                    }
                  ]
                }
              }
            }
          },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/keys/create/": {
      "post": {
        "tags": ["API keys"],
        "operationId": "createApiKey",
        "summary": "Create an API key",
        "description": "Creates a named key. The response is the **only** time the full `key` (`figg_` + 40 hex characters) is returned — only a SHA-256 hash and the 12-character display `prefix` are stored. An account can hold at most 20 active keys. Works with a session or an existing API key.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["name"],
                "properties": {
                  "name": { "type": "string", "minLength": 1, "maxLength": 64, "description": "Human label for the key (e.g. \"ci-pipeline\")." }
                }
              },
              "example": { "name": "ci-pipeline" }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Key created. Store `key` now; it is never shown again.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ApiKeyCreated" },
                "example": {
                  "uuid": "7f3b9a10-2d4e-4c6f-8a1b-5e7d9c0f2a34",
                  "name": "ci-pipeline",
                  "key": "figg_1f2e3d4c5b6a79880716253443526170817263f4",
                  "prefix": "figg_1f2e3d4",
                  "created_at": "2026-07-06T09:00:00Z"
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/ValidationError" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/keys/": {
      "get": {
        "tags": ["API keys"],
        "operationId": "listApiKeys",
        "summary": "List API keys",
        "description": "The account's keys — never the secret, only the display `prefix`. `last_used_at` updates at most once per 60 seconds per key.",
        "parameters": [{ "$ref": "#/components/parameters/Page" }],
        "responses": {
          "200": {
            "description": "One page of keys.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["pagination", "data"],
                  "properties": {
                    "pagination": { "$ref": "#/components/schemas/Pagination" },
                    "data": { "type": "array", "items": { "$ref": "#/components/schemas/ApiKey" } }
                  }
                },
                "example": {
                  "pagination": { "next": null, "previous": null, "current_page": 1, "total_pages": 1, "total_items": 2 },
                  "data": [
                    {
                      "uuid": "7f3b9a10-2d4e-4c6f-8a1b-5e7d9c0f2a34",
                      "name": "ci-pipeline",
                      "prefix": "figg_1f2e3d4",
                      "last_used_at": "2026-07-06T08:59:00Z",
                      "revoked": false,
                      "created_at": "2026-07-01T12:00:00Z"
                    }
                  ]
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/keys/{uuid}/revoke/": {
      "post": {
        "tags": ["API keys"],
        "operationId": "revokeApiKey",
        "summary": "Revoke an API key",
        "description": "Immediately and permanently stops the key authenticating. Idempotent. Revoked keys no longer count toward the 20-key cap.",
        "parameters": [
          {
            "name": "uuid",
            "in": "path",
            "required": true,
            "description": "UUID of the key (from the list endpoint).",
            "schema": { "type": "string", "format": "uuid" }
          }
        ],
        "responses": {
          "200": {
            "description": "Key revoked.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["revoked"],
                  "properties": { "revoked": { "type": "boolean", "const": true } }
                },
                "example": { "revoked": true }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/usage/summary/": {
      "get": {
        "tags": ["Usage"],
        "operationId": "getUsageSummary",
        "summary": "Usage summary",
        "description": "Plan, credit balance, in-flight jobs, the last 30 days of history and per-model totals for the current monthly credit period. `plan` is `null` while the account has no active subscription. Also backs the `get_usage` MCP tool.",
        "responses": {
          "200": {
            "description": "Current usage.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/UsageSummary" },
                "example": {
                  "plan": { "slug": "pro", "name": "Pro", "monthly_credits": 1100, "parallel_limit": 5, "price_monthly_cents": 2900 },
                  "credits": {
                    "balance": 812,
                    "used_this_period": 288,
                    "granted_this_period": 1100,
                    "rollover": 0,
                    "period_start": "2026-07-01T00:00:00Z",
                    "period_end": "2026-08-01T00:00:00Z"
                  },
                  "jobs": { "in_flight": 1, "parallel_limit": 5 },
                  "history": [{ "date": "2026-07-01", "credits_used": 78, "generations": 4 }],
                  "by_model": [
                    { "model": "Kling", "credits": 210, "count": 6 },
                    { "model": "Nano Banana 2", "credits": 64, "count": 8 }
                  ]
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/ledger/": {
      "get": {
        "tags": ["Usage"],
        "operationId": "listLedgerEntries",
        "summary": "List credit ledger entries",
        "description": "Every credit movement on the account, newest first, read-only. `amount` is signed and `balance_after` is the running balance. Entry types: `grant`, `burn`, `refund`, `rollover`, `expiry`, `topup`.",
        "parameters": [{ "$ref": "#/components/parameters/Page" }],
        "responses": {
          "200": {
            "description": "One page of ledger entries.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["pagination", "data"],
                  "properties": {
                    "pagination": { "$ref": "#/components/schemas/Pagination" },
                    "data": { "type": "array", "items": { "$ref": "#/components/schemas/LedgerEntry" } }
                  }
                },
                "example": {
                  "pagination": { "next": null, "previous": null, "current_page": 1, "total_pages": 1, "total_items": 3 },
                  "data": [
                    {
                      "uuid": "9a8b7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d",
                      "entry_type": "burn",
                      "amount": -35,
                      "balance_after": 812,
                      "description": "Kling generation 0b8f6a2e",
                      "created_at": "2026-07-06T09:00:00Z"
                    }
                  ]
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/billing/plans/": {
      "get": {
        "tags": ["Billing"],
        "operationId": "listPlans",
        "summary": "List plans (public)",
        "security": [],
        "description": "The four paid plans — public, no authentication. Billing is **annual only**: `price_monthly_cents` is the display sticker and `price_yearly_cents` (always 12 × the sticker) is the only amount ever charged. `highlight` marks the plan to present as the default (Pro).",
        "responses": {
          "200": {
            "description": "All available plans.",
            "content": {
              "application/json": {
                "schema": { "type": "array", "items": { "$ref": "#/components/schemas/BillingPlan" } },
                "example": [
                  { "slug": "lite", "name": "Lite", "price_monthly_cents": 600, "price_yearly_cents": 7200, "monthly_credits": 200, "parallel_limit": 2, "features": ["200 credits a month", "2 generations in parallel"], "highlight": false },
                  { "slug": "starter", "name": "Starter", "price_monthly_cents": 1200, "price_yearly_cents": 14400, "monthly_credits": 400, "parallel_limit": 3, "features": ["400 credits a month", "3 generations in parallel"], "highlight": false },
                  { "slug": "pro", "name": "Pro", "price_monthly_cents": 2900, "price_yearly_cents": 34800, "monthly_credits": 1100, "parallel_limit": 5, "features": ["1,100 credits a month", "5 generations in parallel"], "highlight": true },
                  { "slug": "studio", "name": "Studio", "price_monthly_cents": 7500, "price_yearly_cents": 90000, "monthly_credits": 3000, "parallel_limit": 8, "features": ["3,000 credits a month", "8 generations in parallel"], "highlight": false }
                ]
              }
            }
          },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/billing/status/": {
      "get": {
        "tags": ["Billing"],
        "operationId": "getBillingStatus",
        "summary": "Billing status",
        "description": "The account's subscription state. `interval` is always `\"yearly\"`. `plan` is `null` while no subscription is active (creating generations then returns `402 subscription_required`). `has_payment_method` indicates whether automatic top-ups can be enabled.",
        "responses": {
          "200": {
            "description": "Subscription state.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/BillingStatus" },
                "example": {
                  "plan": { "slug": "pro", "name": "Pro", "parallel_limit": 5 },
                  "status": "active",
                  "interval": "yearly",
                  "current_period_end": "2027-07-01T00:00:00Z",
                  "cancel_at_period_end": false,
                  "processor": "stripe",
                  "has_payment_method": true
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/billing/checkout/": {
      "post": {
        "tags": ["Billing"],
        "operationId": "createCheckoutSession",
        "summary": "Start subscription checkout",
        "description": "Returns a Stripe Checkout URL for the chosen plan, to be completed in a browser. Billing is annual; an `interval` field, if sent, is ignored. (In local keyless development the plan activates directly and `checkout_url` is `null` with `activated: true`.)",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["plan"],
                "properties": {
                  "plan": { "$ref": "#/components/schemas/PlanSlug" }
                }
              },
              "example": { "plan": "pro" }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Checkout session created.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/CheckoutResponse" },
                "example": { "checkout_url": "https://checkout.stripe.com/c/pay/cs_test_a1b2c3" }
              }
            }
          },
          "400": { "$ref": "#/components/responses/ValidationError" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/billing/portal/": {
      "post": {
        "tags": ["Billing"],
        "operationId": "createPortalSession",
        "summary": "Open the billing portal",
        "description": "Returns a Stripe billing-portal URL for managing the payment method, invoices and cancellation. Cancelling stops the next annual renewal; the plan stays active until the end of the paid period.",
        "responses": {
          "200": {
            "description": "Portal session created.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["portal_url"],
                  "properties": {
                    "portal_url": { "type": ["string", "null"], "format": "uri", "description": "Stripe billing portal URL (`null` only in local keyless development)." }
                  }
                },
                "example": { "portal_url": "https://billing.stripe.com/p/session/test_a1b2c3" }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/billing/topup/settings/": {
      "get": {
        "tags": ["Billing"],
        "operationId": "getTopupSettings",
        "summary": "Get automatic top-up settings",
        "description": "Current auto top-up configuration. `preview_credits` is read-only: how many credits `auto_topup_amount_cents` buys at the current plan rate — `floor(amount_cents × monthly_credits ÷ price_monthly_cents)`.",
        "responses": {
          "200": {
            "description": "Current settings.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/TopupSettings" },
                "example": { "auto_topup_enabled": true, "auto_topup_amount_cents": 5000, "auto_topup_threshold_credits": 50, "preview_credits": 1896 }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      },
      "patch": {
        "tags": ["Billing"],
        "operationId": "updateTopupSettings",
        "summary": "Update automatic top-up settings",
        "description": "Enables or tunes automatic top-ups. Enabling requires an active subscription and a payment method on file. When enabled, a top-up fires (1) on generation create if the balance cannot cover the cost, and (2) after any burn that leaves the balance below `auto_topup_threshold_credits`. At most one automatic top-up fires per hour.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "auto_topup_enabled": { "type": "boolean" },
                  "auto_topup_amount_cents": { "type": "integer", "minimum": 5000, "description": "Cents charged per automatic top-up; minimum 5000 ($50)." },
                  "auto_topup_threshold_credits": { "type": "integer", "minimum": 0, "description": "Balance level that triggers a top-up." }
                }
              },
              "example": { "auto_topup_enabled": true, "auto_topup_amount_cents": 5000, "auto_topup_threshold_credits": 50 }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated settings.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/TopupSettings" },
                "example": { "auto_topup_enabled": true, "auto_topup_amount_cents": 5000, "auto_topup_threshold_credits": 50, "preview_credits": 1896 }
              }
            }
          },
          "400": { "$ref": "#/components/responses/ValidationError" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "402": {
            "description": "No active subscription, or no payment method on file.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Error" },
                "example": { "error": "subscription_required", "detail": "An active subscription and a saved payment method are required for automatic top-ups." }
              }
            }
          },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/billing/topup/": {
      "post": {
        "tags": ["Billing"],
        "operationId": "createTopup",
        "summary": "Buy a one-off credit top-up",
        "description": "Returns a Stripe Checkout URL for a one-off credit purchase. Minimum 5000 cents ($50). Credits are converted at the account's current plan rate — `floor(amount_cents × monthly_credits ÷ price_monthly_cents)`; e.g. $50 on Pro buys 1,896 credits. The grant lands as a ledger entry of type `topup` once payment completes.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["amount_cents"],
                "properties": {
                  "amount_cents": { "type": "integer", "minimum": 5000, "description": "Amount in cents; minimum 5000 ($50)." }
                }
              },
              "example": { "amount_cents": 5000 }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Checkout session created.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/CheckoutResponse" },
                "example": { "checkout_url": "https://checkout.stripe.com/c/pay/cs_test_d4e5f6" }
              }
            }
          },
          "400": { "$ref": "#/components/responses/ValidationError" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "402": {
            "description": "No active subscription — top-ups require one.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Error" },
                "example": { "error": "subscription_required", "detail": "An active subscription is required to top up credits." }
              }
            }
          },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/billing/webhooks/stripe/": {
      "post": {
        "tags": ["Billing"],
        "operationId": "stripeWebhook",
        "summary": "Stripe webhook (server-to-server)",
        "security": [],
        "description": "Receives payment events from Stripe (subscriptions, top-ups, and affiliate commission triggers on `invoice.paid`). Signature-verified; fast-acknowledges with 200. Called by Stripe only — API clients never call this.",
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "type": "object", "description": "Stripe event payload." } } }
        },
        "responses": {
          "200": { "description": "Event acknowledged." },
          "400": { "description": "Invalid signature or payload." }
        }
      }
    },
    "/api/affiliate/enroll/": {
      "post": {
        "tags": ["Affiliate"],
        "operationId": "enrollAffiliate",
        "summary": "Enrol as an affiliate",
        "security": [{ "cookieAuth": [] }, { "bearerAuth": [] }],
        "description": "Makes the current account an affiliate and returns its unique referral code and link. 30% recurring commission is earned for the lifetime of every customer who subscribes through the link, paid via Stripe Connect once onboarding is complete.",
        "responses": {
          "200": {
            "description": "The account is now an affiliate.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/AffiliateEnroll" },
                "example": { "code": "ada-9f3b", "referral_url": "https://app.figgshield.ai/affiliate?ref=ada-9f3b" }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/affiliate/status/": {
      "get": {
        "tags": ["Affiliate"],
        "operationId": "getAffiliateStatus",
        "summary": "Affiliate status and earnings",
        "security": [{ "cookieAuth": [] }, { "bearerAuth": [] }],
        "description": "The affiliate's referral link, Stripe Connect onboarding/payout state, lifetime and pending earnings, and a recent monthly breakdown. All monetary amounts are integer cents.",
        "responses": {
          "200": {
            "description": "Affiliate status.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/AffiliateStatus" },
                "example": {
                  "code": "ada-9f3b",
                  "referral_url": "https://app.figgshield.ai/affiliate?ref=ada-9f3b",
                  "payout_enabled": true,
                  "connect_onboarded": true,
                  "stats": { "referrals": 42, "active": 31, "lifetime_earnings_cents": 184500, "pending_cents": 9300, "paid_cents": 175200 },
                  "recent": [
                    { "month": "2026-06", "referrals": 5, "commission_cents": 18600 },
                    { "month": "2026-05", "referrals": 3, "commission_cents": 12400 }
                  ]
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/affiliate/onboarding-link/": {
      "post": {
        "tags": ["Affiliate"],
        "operationId": "createAffiliateOnboardingLink",
        "summary": "Stripe Connect onboarding link",
        "security": [{ "cookieAuth": [] }, { "bearerAuth": [] }],
        "description": "Returns a Stripe Connect Express onboarding (or refresh) URL. The affiliate completes it to enable payouts; until then `payout_enabled` is false and commissions accrue as `pending`.",
        "responses": {
          "200": {
            "description": "Onboarding link created.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["url"],
                  "properties": { "url": { "type": "string", "format": "uri", "description": "Stripe Connect onboarding URL." } }
                },
                "example": { "url": "https://connect.stripe.com/setup/e/acct_1a2b3c/xyz" }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/admin/metrics/": {
      "get": {
        "tags": ["Admin"],
        "operationId": "getAdminMetrics",
        "summary": "Platform metrics (staff only)",
        "security": [{ "bearerAuth": [] }, { "apiKeyAuth": [] }, { "cookieAuth": [] }],
        "description": "Aggregate platform metrics for staff accounts: user counts and onboarding funnel (`profile`, `plan`, `complete`), active subscriptions by plan, revenue run-rate and top-ups, provider costs, gross margin, and 30-day daily and per-model breakdowns. Requires a staff account; any non-staff caller receives `403 admin_required`.",
        "responses": {
          "200": {
            "description": "Current platform metrics.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/AdminMetrics" },
                "example": {
                  "generated_at": "2026-07-06T09:00:00Z",
                  "users": { "total": 4120, "new_30d": 318, "onboarding": { "profile": 44, "plan": 61, "complete": 4015 } },
                  "subscriptions": {
                    "active_total": 2870,
                    "cancel_at_period_end": 63,
                    "by_plan": [
                      { "slug": "lite", "name": "Lite", "count": 640, "yearly_cents": 7200 },
                      { "slug": "starter", "name": "Starter", "count": 980, "yearly_cents": 14400 },
                      { "slug": "pro", "name": "Pro", "count": 1080, "yearly_cents": 34800 },
                      { "slug": "studio", "name": "Studio", "count": 170, "yearly_cents": 90000 }
                    ]
                  },
                  "revenue": { "run_rate_yearly_cents": 89078400, "effective_monthly_cents": 7423200, "topups_30d_cents": 512000 },
                  "costs": { "provider_30d_cents": 2140000, "per_active_user_monthly_cents": 745 },
                  "margins": { "monthly_revenue_cents": 7423200, "monthly_cost_cents": 2140000, "gross_margin_pct": 71.2 },
                  "daily": [{ "date": "2026-07-01", "signups": 11, "new_subscriptions": 7, "credits_used": 24310, "provider_cost_cents": 71200 }],
                  "by_model": [
                    { "model": "Kling", "generations": 1820, "credits": 63700, "provider_cost_cents": 637000 },
                    { "model": "Nano Banana 2", "generations": 5140, "credits": 41120, "provider_cost_cents": 174760 }
                  ]
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": {
            "description": "The caller is authenticated but is not a staff account.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Error" },
                "example": { "error": "admin_required", "detail": "This endpoint requires a staff account." }
              }
            }
          },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/health/": {
      "get": {
        "tags": ["Meta"],
        "operationId": "healthCheck",
        "summary": "Health check (public)",
        "security": [],
        "description": "Liveness probe. Public, unauthenticated.",
        "responses": {
          "200": {
            "description": "Service is up.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["status", "version"],
                  "properties": {
                    "status": { "type": "string", "const": "ok" },
                    "version": { "type": "string" }
                  }
                },
                "example": { "status": "ok", "version": "2.0.0" }
              }
            }
          }
        }
      }
    },
    "/authentication/social/config/": {
      "get": {
        "tags": ["Authentication (session)"],
        "operationId": "getSocialConfig",
        "summary": "SSO provider configuration (public)",
        "security": [],
        "description": "Which SSO providers are configured. The app hides buttons for providers that are `false`.",
        "responses": {
          "200": {
            "description": "Provider configuration.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["google", "apple", "github"],
                  "properties": {
                    "google": { "type": "boolean" },
                    "apple": { "type": "boolean" },
                    "github": { "type": "boolean" }
                  }
                },
                "example": { "google": true, "apple": true, "github": true }
              }
            }
          }
        }
      }
    },
    "/authentication/me/": {
      "get": {
        "tags": ["Authentication (session)"],
        "operationId": "getMe",
        "summary": "Current user",
        "security": [{ "cookieAuth": [] }],
        "description": "The authenticated user's profile, plan and credit balance. `plan` is `null` until a subscription is active.",
        "responses": {
          "200": {
            "description": "The current user.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/UserEnvelope" },
                "example": {
                  "user": {
                    "uuid": "5d4c3b2a-1908-4756-a3b2-c1d0e9f8a7b6",
                    "email": "ada@example.com",
                    "first_name": "Ada",
                    "language": "en",
                    "use_case": "short-form video",
                    "onboarding_step": "complete",
                    "is_staff": false,
                    "social_provider": "google",
                    "plan": { "slug": "pro", "name": "Pro", "parallel_limit": 5 },
                    "credits": { "balance": 812 },
                    "is_affiliate": false
                  }
                }
              }
            }
          },
          "401": {
            "description": "No valid session.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
          }
        }
      },
      "patch": {
        "tags": ["Authentication (session)"],
        "operationId": "updateMe",
        "summary": "Update profile",
        "security": [{ "cookieAuth": [] }],
        "description": "Updates profile fields.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "first_name": { "type": "string" },
                  "language": { "type": "string", "description": "Preferred UI language code (e.g. \"en\")." },
                  "use_case": { "type": "string" }
                }
              },
              "example": { "first_name": "Ada", "language": "en" }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated user.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UserEnvelope" } } }
          },
          "400": { "$ref": "#/components/responses/ValidationError" },
          "401": {
            "description": "No valid session.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
          }
        }
      }
    },
    "/authentication/logout/": {
      "post": {
        "tags": ["Authentication (session)"],
        "operationId": "logout",
        "summary": "Log out",
        "security": [{ "cookieAuth": [] }],
        "description": "Clears the session cookie.",
        "responses": {
          "204": { "description": "Logged out; cookie cleared." }
        }
      }
    },
    "/authentication/onboarding/advance/": {
      "post": {
        "tags": ["Authentication (session)"],
        "operationId": "advanceOnboarding",
        "summary": "Advance onboarding",
        "security": [{ "cookieAuth": [] }],
        "description": "Marks an onboarding step complete. Steps: `profile` → `plan` → `complete`. The plan step lists the four paid tiers with Pro pre-selected and cannot be skipped.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["step_completed"],
                "properties": {
                  "step_completed": { "type": "string", "enum": ["profile", "plan"] },
                  "data": { "type": "object", "description": "Optional step payload." }
                }
              },
              "example": { "step_completed": "profile", "data": { "use_case": "short-form video" } }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated user with the new onboarding_step.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UserEnvelope" } } }
          },
          "400": { "$ref": "#/components/responses/ValidationError" },
          "401": {
            "description": "No valid session.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "A `figg_...` API key in the Authorization header: `Authorization: Bearer figg_<40 hex>`. Create keys in the app under Account → API keys. Authenticates the REST API, the CLI header, and (as an alternative to OAuth) the MCP endpoint."
      },
      "apiKeyAuth": {
        "type": "apiKey",
        "in": "header",
        "name": "X-API-Key",
        "description": "The same `figg_...` API key in the X-API-Key header — an alternative to the Authorization header on REST endpoints."
      },
      "cookieAuth": {
        "type": "apiKey",
        "in": "cookie",
        "name": "token",
        "description": "HttpOnly session cookie set by SSO sign-in. Used by the browser app; also accepted on /api/ endpoints. The only scheme accepted on /authentication/ endpoints."
      },
      "oauth2": {
        "type": "oauth2",
        "description": "OAuth 2.1 access token (JWT, RS256) for the MCP endpoint, with PKCE (S256). Figgshield is the authorization server; Google/Apple/GitHub are upstream identity providers. Discovery: /.well-known/oauth-protected-resource and /.well-known/oauth-authorization-server.",
        "flows": {
          "authorizationCode": {
            "authorizationUrl": "https://api.figgshield.ai/authorize",
            "tokenUrl": "https://api.figgshield.ai/token",
            "scopes": {
              "figgshield:read": "Read generations, library and usage.",
              "figgshield:write": "Create generations."
            }
          }
        }
      }
    },
    "parameters": {
      "Page": {
        "name": "page",
        "in": "query",
        "required": false,
        "description": "Page number (1-based).",
        "schema": { "type": "integer", "minimum": 1, "default": 1 }
      },
      "GenerationUuid": {
        "name": "uuid",
        "in": "path",
        "required": true,
        "description": "UUID of the generation.",
        "schema": { "type": "string", "format": "uuid" }
      }
    },
    "responses": {
      "ValidationError": {
        "description": "Malformed body or a value out of range. `detail` names the offending field; do not retry unchanged.",
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/Error" },
            "example": { "error": "validation_error", "detail": "duration must be one of [5, 10]." }
          }
        }
      },
      "Unauthorized": {
        "description": "Missing, malformed, unknown or revoked credential.",
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/Error" },
            "example": { "error": "invalid_api_key", "detail": "This API key is invalid or has been revoked." }
          }
        }
      },
      "NotFound": {
        "description": "No such resource, or it belongs to another account (objects are owner-scoped).",
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/Error" },
            "example": { "error": "not_found", "detail": "Generation not found." }
          }
        }
      },
      "RateLimited": {
        "description": "Too many requests — back off exponentially and retry.",
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/Error" },
            "example": { "error": "rate_limited", "detail": "Too many requests. Slow down and retry." }
          }
        }
      }
    },
    "schemas": {
      "Error": {
        "type": "object",
        "description": "Every error response uses this envelope. Match on `error` (a stable machine code); `detail` is a human sentence whose wording can change. Machine codes: validation_error (400), invalid_api_key (401), insufficient_credits (402), subscription_required (402), admin_required (403), not_found (404), parallel_limit_reached (409), rate_limited (429), model_unavailable (503).",
        "required": ["error", "detail"],
        "properties": {
          "error": {
            "type": "string",
            "enum": [
              "validation_error",
              "invalid_api_key",
              "insufficient_credits",
              "subscription_required",
              "admin_required",
              "not_found",
              "parallel_limit_reached",
              "rate_limited",
              "model_unavailable"
            ],
            "description": "Stable machine code."
          },
          "detail": { "type": "string", "description": "Human-readable sentence for logs and messages." }
        }
      },
      "Pagination": {
        "type": "object",
        "description": "Pagination metadata carried by every list envelope.",
        "required": ["next", "previous", "current_page", "total_pages", "total_items"],
        "properties": {
          "next": { "type": ["string", "null"], "format": "uri", "description": "URL of the next page, or null on the last page." },
          "previous": { "type": ["string", "null"], "format": "uri", "description": "URL of the previous page, or null on the first page." },
          "current_page": { "type": "integer", "minimum": 1 },
          "total_pages": { "type": "integer", "minimum": 0 },
          "total_items": { "type": "integer", "minimum": 0 }
        }
      },
      "GenerationStatus": {
        "type": "string",
        "enum": ["queued", "running", "succeeded", "failed"],
        "description": "Lifecycle: queued → running → succeeded | failed. Failed generations refund their credits automatically."
      },
      "ModelKind": {
        "type": "string",
        "enum": ["video", "image"],
        "description": "Determines which generation params apply."
      },
      "ModelStatus": {
        "type": "string",
        "enum": ["online", "degraded", "offline"],
        "description": "Live health: online (normal), degraded (slow or flaky — failures auto-refund), offline (creating returns 503 model_unavailable)."
      },
      "PlanSlug": {
        "type": "string",
        "enum": ["lite", "starter", "pro", "studio"],
        "description": "The four paid plans. There is no free tier."
      },
      "SocialProvider": {
        "type": "string",
        "enum": ["google", "apple", "github"],
        "description": "The SSO identity provider the account signed in with."
      },
      "LedgerEntryType": {
        "type": "string",
        "enum": ["grant", "burn", "refund", "rollover", "expiry", "topup"],
        "description": "grant: monthly plan credits; burn: generation charged; refund: failed generation returned; rollover: carried into a new period; expiry: rollover aged out or subscription lapsed; topup: manual or automatic top-up credited."
      },
      "GenerationParams": {
        "type": "object",
        "description": "Model-dependent settings; each model's params_schema (see GET /api/models/) lists the allowed values. Omitted params use defaults.",
        "properties": {
          "duration": { "type": "integer", "description": "Kling only. 5 or 10 seconds; 10 doubles the cost." },
          "resolution": { "type": "string", "description": "Kling: \"720p\" or \"1080p\". Nano Banana 2: \"1K\", \"2K\" or \"4K\"." },
          "audio": { "type": "boolean", "description": "Kling only. Default false; true doubles the cost." },
          "aspect_ratio": { "type": "string", "description": "Both models. One of params_schema.aspect_ratios, e.g. \"16:9\"." }
        },
        "additionalProperties": false
      },
      "GenerationCreateRequest": {
        "type": "object",
        "required": ["model", "prompt"],
        "properties": {
          "model": { "type": "string", "enum": ["kling-video", "nano-banana-2"], "description": "Model slug." },
          "prompt": { "type": "string", "description": "What to generate." },
          "params": { "$ref": "#/components/schemas/GenerationParams" }
        }
      },
      "Generation": {
        "type": "object",
        "description": "One video or image job.",
        "required": ["uuid", "model", "model_name", "kind", "prompt", "params", "status", "output_url", "thumbnail_url", "credits_charged", "error_message", "created_at", "completed_at", "duration_ms", "is_watermarked"],
        "properties": {
          "uuid": { "type": "string", "format": "uuid" },
          "model": { "type": "string", "description": "Model slug (kling-video, nano-banana-2)." },
          "model_name": { "type": "string", "description": "Model display name (Kling, Nano Banana 2)." },
          "kind": { "$ref": "#/components/schemas/ModelKind" },
          "prompt": { "type": "string" },
          "params": { "$ref": "#/components/schemas/GenerationParams" },
          "status": { "$ref": "#/components/schemas/GenerationStatus" },
          "output_url": { "type": ["string", "null"], "format": "uri", "description": "Download URL; null until the job succeeds." },
          "thumbnail_url": { "type": ["string", "null"], "format": "uri", "description": "Preview image; null until the job succeeds." },
          "credits_charged": { "type": "integer", "description": "Credits charged for this job (refunded automatically if it fails)." },
          "error_message": { "type": ["string", "null"], "description": "Failure reason; set only when status is failed." },
          "created_at": { "type": "string", "format": "date-time" },
          "completed_at": { "type": ["string", "null"], "format": "date-time" },
          "duration_ms": { "type": ["integer", "null"], "description": "Wall-clock job duration once finished." },
          "is_watermarked": { "type": "boolean", "description": "Compatibility field; always false — outputs are never watermarked.", "const": false }
        }
      },
      "ParamsSchema": {
        "type": "object",
        "description": "The values a model accepts when creating a generation.",
        "properties": {
          "durations": { "type": "array", "items": { "type": "integer" }, "description": "Allowed durations in seconds (Kling: [5, 10])." },
          "resolutions": { "type": "array", "items": { "type": "string" }, "description": "Allowed resolutions (Kling: 720p/1080p; Nano Banana 2: 1K/2K/4K)." },
          "audio": { "type": "boolean", "description": "Whether the model can generate audio (Kling)." },
          "aspect_ratios": { "type": "array", "items": { "type": "string" }, "description": "Allowed aspect ratios." }
        }
      },
      "Model": {
        "type": "object",
        "description": "A live model (Kling or Nano Banana 2).",
        "required": ["uuid", "slug", "name", "provider", "kind", "description", "credits_base", "status", "sort_order", "params_schema"],
        "properties": {
          "uuid": { "type": "string", "format": "uuid" },
          "slug": { "type": "string", "enum": ["kling-video", "nano-banana-2"], "description": "Stable identifier; pass as `model` when creating a generation." },
          "name": { "type": "string" },
          "provider": { "type": "string", "description": "Hosting provider (fal for Kling, google for Nano Banana 2). Informational." },
          "kind": { "$ref": "#/components/schemas/ModelKind" },
          "description": { "type": "string" },
          "credits_base": { "type": "integer", "description": "Base credits per generation (35 for Kling, 8 for Nano Banana 2). Duration and audio can multiply it." },
          "status": { "$ref": "#/components/schemas/ModelStatus" },
          "sort_order": { "type": "integer", "description": "Suggested display order." },
          "params_schema": { "$ref": "#/components/schemas/ParamsSchema" }
        }
      },
      "ApiKey": {
        "type": "object",
        "description": "An API key as returned by list — never contains the secret.",
        "required": ["uuid", "name", "prefix", "last_used_at", "revoked", "created_at"],
        "properties": {
          "uuid": { "type": "string", "format": "uuid" },
          "name": { "type": "string", "maxLength": 64 },
          "prefix": { "type": "string", "description": "First 12 characters of the key, for display (e.g. \"figg_1f2e3d4\")." },
          "last_used_at": { "type": ["string", "null"], "format": "date-time", "description": "Updated at most once per 60 seconds." },
          "revoked": { "type": "boolean" },
          "created_at": { "type": "string", "format": "date-time" }
        }
      },
      "ApiKeyCreated": {
        "type": "object",
        "description": "Returned once, at creation — the only response ever containing the full key.",
        "required": ["uuid", "name", "key", "prefix", "created_at"],
        "properties": {
          "uuid": { "type": "string", "format": "uuid" },
          "name": { "type": "string" },
          "key": { "type": "string", "pattern": "^figg_[0-9a-f]{40}$", "description": "The full secret. Shown once; store it now." },
          "prefix": { "type": "string" },
          "created_at": { "type": "string", "format": "date-time" }
        }
      },
      "LedgerEntry": {
        "type": "object",
        "description": "One credit movement. amount is signed; balance_after is the running balance.",
        "required": ["uuid", "entry_type", "amount", "balance_after", "description", "created_at"],
        "properties": {
          "uuid": { "type": "string", "format": "uuid" },
          "entry_type": { "$ref": "#/components/schemas/LedgerEntryType" },
          "amount": { "type": "integer", "description": "Signed credit delta." },
          "balance_after": { "type": "integer" },
          "description": { "type": "string" },
          "created_at": { "type": "string", "format": "date-time" }
        }
      },
      "UsagePlan": {
        "type": ["object", "null"],
        "description": "Current plan; null while no subscription is active.",
        "required": ["slug", "name", "monthly_credits", "parallel_limit", "price_monthly_cents"],
        "properties": {
          "slug": { "$ref": "#/components/schemas/PlanSlug" },
          "name": { "type": "string" },
          "monthly_credits": { "type": "integer" },
          "parallel_limit": { "type": "integer" },
          "price_monthly_cents": { "type": "integer", "description": "Display sticker in cents; the yearly charge is 12 × this." }
        }
      },
      "UsageSummary": {
        "type": "object",
        "description": "Current-period usage: plan, credits, jobs, 30-day history and per-model totals.",
        "required": ["plan", "credits", "jobs", "history", "by_model"],
        "properties": {
          "plan": { "$ref": "#/components/schemas/UsagePlan" },
          "credits": {
            "type": "object",
            "required": ["balance", "used_this_period", "granted_this_period", "rollover", "period_start", "period_end"],
            "properties": {
              "balance": { "type": "integer", "description": "Spendable credits right now, including rollover and top-ups." },
              "used_this_period": { "type": "integer" },
              "granted_this_period": { "type": "integer" },
              "rollover": { "type": "integer", "description": "Credits carried from earlier periods." },
              "period_start": { "type": "string", "format": "date-time" },
              "period_end": { "type": "string", "format": "date-time" }
            }
          },
          "jobs": {
            "type": "object",
            "required": ["in_flight", "parallel_limit"],
            "properties": {
              "in_flight": { "type": "integer" },
              "parallel_limit": { "type": "integer" }
            }
          },
          "history": {
            "type": "array",
            "description": "Last 30 days, one entry per day.",
            "items": {
              "type": "object",
              "required": ["date", "credits_used", "generations"],
              "properties": {
                "date": { "type": "string", "format": "date" },
                "credits_used": { "type": "integer" },
                "generations": { "type": "integer" }
              }
            }
          },
          "by_model": {
            "type": "array",
            "description": "Per-model totals for the current period.",
            "items": {
              "type": "object",
              "required": ["model", "credits", "count"],
              "properties": {
                "model": { "type": "string", "description": "Model display name." },
                "credits": { "type": "integer" },
                "count": { "type": "integer" }
              }
            }
          }
        }
      },
      "BillingPlan": {
        "type": "object",
        "description": "A subscribable plan. Billing is annual only: price_monthly_cents is the display sticker; price_yearly_cents (12 × sticker) is the only amount charged.",
        "required": ["slug", "name", "price_monthly_cents", "price_yearly_cents", "monthly_credits", "parallel_limit", "features", "highlight"],
        "properties": {
          "slug": { "$ref": "#/components/schemas/PlanSlug" },
          "name": { "type": "string" },
          "price_monthly_cents": { "type": "integer", "description": "Display sticker in cents (e.g. 600 = $6/month)." },
          "price_yearly_cents": { "type": "integer", "description": "The amount actually charged, once a year. Always 12 × price_monthly_cents." },
          "monthly_credits": { "type": "integer", "description": "Credits granted at the start of each monthly credit period." },
          "parallel_limit": { "type": "integer", "description": "Maximum generations in flight at once." },
          "features": { "type": "array", "items": { "type": "string" } },
          "highlight": { "type": "boolean", "description": "True for the plan to present as the default (Pro)." }
        }
      },
      "BillingStatus": {
        "type": "object",
        "description": "Subscription state of the account.",
        "required": ["plan", "status", "interval", "current_period_end", "cancel_at_period_end", "processor", "has_payment_method"],
        "properties": {
          "plan": {
            "type": ["object", "null"],
            "description": "Current plan summary; null while no subscription is active.",
            "properties": {
              "slug": { "$ref": "#/components/schemas/PlanSlug" },
              "name": { "type": "string" },
              "parallel_limit": { "type": "integer" }
            }
          },
          "status": { "type": "string", "description": "Subscription status (e.g. \"active\", \"past_due\", \"canceled\")." },
          "interval": { "type": "string", "const": "yearly", "description": "Always \"yearly\" — there is no monthly billing." },
          "current_period_end": { "type": ["string", "null"], "format": "date-time" },
          "cancel_at_period_end": { "type": "boolean", "description": "True once cancellation is scheduled; the plan stays active until current_period_end." },
          "processor": { "type": ["string", "null"], "enum": ["stripe", null] },
          "has_payment_method": { "type": "boolean", "description": "Whether a payment method is on file — required to enable automatic top-ups." }
        }
      },
      "TopupSettings": {
        "type": "object",
        "description": "Automatic top-up configuration.",
        "required": ["auto_topup_enabled", "auto_topup_amount_cents", "auto_topup_threshold_credits", "preview_credits"],
        "properties": {
          "auto_topup_enabled": { "type": "boolean" },
          "auto_topup_amount_cents": { "type": "integer", "minimum": 5000, "description": "Cents charged per automatic top-up; minimum 5000 ($50)." },
          "auto_topup_threshold_credits": { "type": "integer", "minimum": 0, "description": "Balance level that triggers a top-up." },
          "preview_credits": { "type": "integer", "description": "Read-only: credits the configured amount buys at the current plan rate — floor(amount_cents × monthly_credits ÷ price_monthly_cents)." }
        }
      },
      "CheckoutResponse": {
        "type": "object",
        "description": "A Stripe Checkout session to complete in a browser. In local keyless development checkout_url is null and activated is true (plus credits_granted for top-ups).",
        "required": ["checkout_url"],
        "properties": {
          "checkout_url": { "type": ["string", "null"], "format": "uri" },
          "activated": { "type": "boolean", "description": "Local keyless development only." },
          "credits_granted": { "type": "integer", "description": "Local keyless development only (top-ups)." }
        }
      },
      "AffiliateEnroll": {
        "type": "object",
        "description": "Returned when an account enrols as an affiliate.",
        "required": ["code", "referral_url"],
        "properties": {
          "code": { "type": "string", "description": "The affiliate's unique referral code." },
          "referral_url": { "type": "string", "format": "uri", "description": "The share link (app /affiliate with ?ref=<code>)." }
        }
      },
      "AffiliateStatus": {
        "type": "object",
        "description": "An affiliate's link, payout state and earnings. Monetary amounts are integer cents. 30% recurring commission, lifetime, paid via Stripe Connect.",
        "required": ["code", "referral_url", "payout_enabled", "connect_onboarded", "stats", "recent"],
        "properties": {
          "code": { "type": "string" },
          "referral_url": { "type": "string", "format": "uri" },
          "payout_enabled": { "type": "boolean", "description": "Whether Stripe Connect payouts are enabled for this affiliate." },
          "connect_onboarded": { "type": "boolean", "description": "Whether Stripe Connect onboarding is complete." },
          "stats": {
            "type": "object",
            "required": ["referrals", "active", "lifetime_earnings_cents", "pending_cents", "paid_cents"],
            "properties": {
              "referrals": { "type": "integer", "description": "Total referred customers." },
              "active": { "type": "integer", "description": "Referred customers with an active subscription." },
              "lifetime_earnings_cents": { "type": "integer" },
              "pending_cents": { "type": "integer", "description": "Commission accrued but not yet paid out." },
              "paid_cents": { "type": "integer" }
            }
          },
          "recent": {
            "type": "array",
            "description": "Recent monthly breakdown, newest first.",
            "items": {
              "type": "object",
              "required": ["month", "referrals", "commission_cents"],
              "properties": {
                "month": { "type": "string", "description": "Month as YYYY-MM." },
                "referrals": { "type": "integer" },
                "commission_cents": { "type": "integer" }
              }
            }
          }
        }
      },
      "User": {
        "type": "object",
        "description": "An account. plan is null until a subscription is active; is_staff is false for every ordinary account. social_provider is the SSO provider used to sign in.",
        "required": ["uuid", "email", "first_name", "language", "use_case", "onboarding_step", "is_staff", "social_provider", "plan", "credits", "is_affiliate"],
        "properties": {
          "uuid": { "type": "string", "format": "uuid" },
          "email": { "type": "string", "format": "email" },
          "first_name": { "type": "string" },
          "language": { "type": "string" },
          "use_case": { "type": ["string", "null"] },
          "onboarding_step": { "type": "string", "enum": ["profile", "plan", "complete"] },
          "is_staff": { "type": "boolean", "description": "Whether the account is a staff account. False for every ordinary account; staff accounts (created out-of-band) additionally have access to GET /api/admin/metrics/." },
          "social_provider": { "$ref": "#/components/schemas/SocialProvider" },
          "plan": {
            "type": ["object", "null"],
            "properties": {
              "slug": { "$ref": "#/components/schemas/PlanSlug" },
              "name": { "type": "string" },
              "parallel_limit": { "type": "integer" }
            }
          },
          "credits": {
            "type": "object",
            "required": ["balance"],
            "properties": {
              "balance": { "type": "integer" }
            }
          },
          "is_affiliate": { "type": "boolean", "description": "Whether the account is enrolled in the affiliate program." }
        }
      },
      "UserEnvelope": {
        "type": "object",
        "required": ["user"],
        "properties": {
          "user": { "$ref": "#/components/schemas/User" }
        }
      },
      "AdminMetrics": {
        "type": "object",
        "description": "Aggregate platform metrics returned by GET /api/admin/metrics/ (staff only). All monetary amounts are integer cents. `daily` and `by_model` cover the last 30 days.",
        "required": ["generated_at", "users", "subscriptions", "revenue", "costs", "margins", "daily", "by_model"],
        "properties": {
          "generated_at": { "type": "string", "format": "date-time", "description": "When these figures were computed." },
          "users": {
            "type": "object",
            "required": ["total", "new_30d", "onboarding"],
            "properties": {
              "total": { "type": "integer", "description": "All registered accounts." },
              "new_30d": { "type": "integer", "description": "Accounts created in the last 30 days." },
              "onboarding": {
                "type": "object",
                "description": "Number of accounts currently at each onboarding step.",
                "required": ["profile", "plan", "complete"],
                "properties": {
                  "profile": { "type": "integer" },
                  "plan": { "type": "integer" },
                  "complete": { "type": "integer" }
                }
              }
            }
          },
          "subscriptions": {
            "type": "object",
            "required": ["active_total", "cancel_at_period_end", "by_plan"],
            "properties": {
              "active_total": { "type": "integer", "description": "Accounts with an active subscription." },
              "cancel_at_period_end": { "type": "integer", "description": "Active subscriptions scheduled to cancel at period end." },
              "by_plan": {
                "type": "array",
                "description": "Active subscription counts per plan.",
                "items": {
                  "type": "object",
                  "required": ["slug", "name", "count", "yearly_cents"],
                  "properties": {
                    "slug": { "$ref": "#/components/schemas/PlanSlug" },
                    "name": { "type": "string" },
                    "count": { "type": "integer" },
                    "yearly_cents": { "type": "integer", "description": "Amount charged per year for this plan (12 × the monthly sticker)." }
                  }
                }
              }
            }
          },
          "revenue": {
            "type": "object",
            "required": ["run_rate_yearly_cents", "effective_monthly_cents", "topups_30d_cents"],
            "properties": {
              "run_rate_yearly_cents": { "type": "integer", "description": "Annualised run-rate from active subscriptions." },
              "effective_monthly_cents": { "type": "integer", "description": "Run-rate expressed per month (yearly ÷ 12)." },
              "topups_30d_cents": { "type": "integer", "description": "Credit top-up revenue over the last 30 days." }
            }
          },
          "costs": {
            "type": "object",
            "required": ["provider_30d_cents", "per_active_user_monthly_cents"],
            "properties": {
              "provider_30d_cents": { "type": "integer", "description": "Estimated provider (model) cost over the last 30 days." },
              "per_active_user_monthly_cents": { "type": "integer", "description": "Provider cost per active subscriber per month." }
            }
          },
          "margins": {
            "type": "object",
            "required": ["monthly_revenue_cents", "monthly_cost_cents", "gross_margin_pct"],
            "properties": {
              "monthly_revenue_cents": { "type": "integer" },
              "monthly_cost_cents": { "type": "integer" },
              "gross_margin_pct": { "type": ["number", "null"], "description": "Gross margin as a percentage; null when monthly revenue is 0." }
            }
          },
          "daily": {
            "type": "array",
            "description": "Per-day figures for the last 30 days.",
            "items": {
              "type": "object",
              "required": ["date", "signups", "new_subscriptions", "credits_used", "provider_cost_cents"],
              "properties": {
                "date": { "type": "string", "format": "date" },
                "signups": { "type": "integer" },
                "new_subscriptions": { "type": "integer" },
                "credits_used": { "type": "integer" },
                "provider_cost_cents": { "type": "integer" }
              }
            }
          },
          "by_model": {
            "type": "array",
            "description": "Per-model totals for the last 30 days.",
            "items": {
              "type": "object",
              "required": ["model", "generations", "credits", "provider_cost_cents"],
              "properties": {
                "model": { "type": "string", "description": "Model display name." },
                "generations": { "type": "integer" },
                "credits": { "type": "integer" },
                "provider_cost_cents": { "type": "integer", "description": "Best-available provider cost estimate for this model over the period." }
              }
            }
          }
        }
      }
    }
  }
}
