{
  "openapi": "3.1.0",
  "info": {
    "title": "Easy RFP — Public API",
    "version": "v57.364",
    "description": "Easy RFP webhook + API endpoints. Cloudflare Pages Functions backing the marketing site and the /app2/ TanStack Start workspace. All paths are mounted at https://easyhotelrfp.com. JSON responses follow the convention { ok: boolean, ... }. Cron endpoints accept both Cloudflare-triggered GET (scheduled) and manual POST guarded by CRON_SECRET. See /docs/api/swagger/ for the live explorer.",
    "contact": {
      "name": "Easy RFP",
      "email": "contact@easyhotelrfp.com",
      "url": "https://easyhotelrfp.com/contact/"
    },
    "license": {
      "name": "Proprietary",
      "url": "https://easyhotelrfp.com/legal/terms/"
    }
  },
  "servers": [
    {
      "url": "https://easyhotelrfp.com",
      "description": "Production"
    }
  ],
  "tags": [
    { "name": "Webhooks", "description": "Stripe + email-platform webhook receivers" },
    { "name": "Cron", "description": "Scheduled jobs (Cloudflare cron triggers)" },
    { "name": "Lead capture", "description": "Form endpoints (contact, lead magnets, RFP audits)" },
    { "name": "Founding 100", "description": "Founding agency program (apply, counter, milestones)" },
    { "name": "Email lifecycle", "description": "One-click unsubscribe + consent logging" },
    { "name": "Referrals", "description": "Referral program code resolution + credit" },
    { "name": "SEO ops", "description": "Search-engine submission utilities" },
    { "name": "Status", "description": "Health probes" },
    { "name": "Export", "description": "White-label PDF generation" }
  ],
  "paths": {
    "/api/health": {
      "get": {
        "tags": ["Status"],
        "summary": "Real-time green/red status dashboard.",
        "description": "Probes Supabase REST, Stripe edge functions, and the cron heartbeat table. Returns 200 when all critical deps are reachable, 503 when at least one critical dep is down. Cron staleness flips checks.crons.<fn>.ok=false but does not affect overall.",
        "responses": {
          "200": { "description": "All critical deps healthy.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/HealthReport" } } } },
          "503": { "description": "At least one critical dep is down.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/HealthReport" } } } }
        }
      }
    },
    "/api/contact": {
      "post": {
        "tags": ["Lead capture"],
        "summary": "Backs the /contact/ form. Sends via Resend to contact@easyhotelrfp.com.",
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ContactPayload" } } } },
        "responses": {
          "200": { "description": "Delivered.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OkWithId" } } } },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "500": { "$ref": "#/components/responses/ServerError" }
        }
      },
      "options": { "summary": "CORS preflight.", "tags": ["Lead capture"], "responses": { "204": { "description": "No content." } } }
    },
    "/api/lead-magnet": {
      "post": {
        "tags": ["Lead capture"],
        "summary": "PDF / template download lead capture (hotel-rfp, migration-checklist).",
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/LeadMagnetPayload" } } } },
        "responses": {
          "200": { "description": "Download link emailed.", "content": { "application/json": { "schema": { "type": "object", "properties": { "ok": { "type": "boolean" }, "download_url": { "type": "string", "format": "uri" } }, "required": ["ok"] } } } },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "500": { "$ref": "#/components/responses/ServerError" }
        }
      },
      "options": { "summary": "CORS preflight.", "tags": ["Lead capture"], "responses": { "204": { "description": "No content." } } }
    },
    "/api/rfp-audit": {
      "post": {
        "tags": ["Lead capture"],
        "summary": "Free RFP Audit lead magnet (capture + Resend report).",
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RfpAuditPayload" } } } },
        "responses": {
          "200": { "description": "Report sent.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OkWithId" } } } },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "413": { "$ref": "#/components/responses/PayloadTooLarge" },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "500": { "$ref": "#/components/responses/ServerError" }
        }
      },
      "options": { "summary": "CORS preflight.", "tags": ["Lead capture"], "responses": { "204": { "description": "No content." } } }
    },
    "/api/rfp-audit-leadmagnet": {
      "post": {
        "tags": ["Lead capture"],
        "summary": "Free RFP Process Audit lead magnet (/tools/free-rfp-audit/).",
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RfpAuditLeadMagnetPayload" } } } },
        "responses": {
          "200": { "description": "Personalised report delivered.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OkWithId" } } } },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "413": { "$ref": "#/components/responses/PayloadTooLarge" },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "500": { "$ref": "#/components/responses/ServerError" }
        }
      },
      "options": { "summary": "CORS preflight.", "tags": ["Lead capture"], "responses": { "204": { "description": "No content." } } }
    },
    "/api/founding-apply": {
      "post": {
        "tags": ["Founding 100"],
        "summary": "Submit a Founding 100 agency application.",
        "description": "Validates payload, inserts row pending, computes 0-100 score, sets status (auto-approved >=80, pending-review 50-79, rejected <50). Receipt email always fires. Cvent gold rule: the 100-slot counter is always rendered from agency_applications_counter view.",
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/FoundingApplyPayload" } } } },
        "responses": {
          "200": { "description": "Application accepted.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/FoundingApplyResponse" } } } },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "500": { "$ref": "#/components/responses/ServerError" }
        }
      },
      "options": { "summary": "CORS preflight.", "tags": ["Founding 100"], "responses": { "204": { "description": "No content." } } }
    },
    "/api/founding-counter": {
      "get": {
        "tags": ["Founding 100"],
        "summary": "Live approved + pending counts for Founding 100 (read-only, 60s cache).",
        "responses": {
          "200": {
            "description": "Counter snapshot. Cached for 60s.",
            "headers": { "Cache-Control": { "schema": { "type": "string" }, "example": "public, max-age=60, s-maxage=60" } },
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/FoundingCounter" } } }
          }
        }
      },
      "options": { "summary": "CORS preflight.", "tags": ["Founding 100"], "responses": { "204": { "description": "No content." } } }
    },
    "/api/founding-milestone-cron": {
      "get": {
        "tags": ["Cron"],
        "summary": "Cloudflare-triggered sweep — Founding milestone emails (Day 30/60/100/180/270/365).",
        "description": "Daily 10:00 UTC. Sends offset-day anniversary emails to Founding members. Logs to email_sends for idempotency.",
        "security": [{ "cronSecret": [] }],
        "responses": {
          "200": { "description": "Sweep complete.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CronRunResult" } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" }
        }
      },
      "post": {
        "tags": ["Cron"],
        "summary": "Manual trigger (CRON_SECRET-guarded).",
        "security": [{ "cronSecret": [] }],
        "responses": {
          "200": { "description": "Sweep complete.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CronRunResult" } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" }
        }
      }
    },
    "/api/welcome-email-cron": {
      "get": {
        "tags": ["Cron"],
        "summary": "Cloudflare-triggered sweep — Welcome v5 sequence (Day 0/2/5/9/13).",
        "description": "Daily 09:00 CET. A/B-tests subject lines via user_id parity. Idempotent via email_sends row check.",
        "security": [{ "cronSecret": [] }],
        "responses": {
          "200": { "description": "Sweep complete.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CronRunResult" } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" }
        }
      },
      "post": {
        "tags": ["Cron"],
        "summary": "Manual trigger (CRON_SECRET-guarded).",
        "security": [{ "cronSecret": [] }],
        "responses": {
          "200": { "description": "Sweep complete.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CronRunResult" } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" }
        }
      }
    },
    "/api/abandonment-cron": {
      "get": {
        "tags": ["Cron"],
        "summary": "Cloudflare-triggered sweep — Cart abandonment recovery (Day 0/1/3).",
        "description": "Daily 10:00 CET. Skips rows where recovered_at OR unsubscribed_at is set or email is in email_unsubscribes (channel=abandonment|all).",
        "security": [{ "cronSecret": [] }],
        "responses": {
          "200": { "description": "Sweep complete.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CronRunResult" } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" }
        }
      },
      "post": {
        "tags": ["Cron"],
        "summary": "Manual trigger (CRON_SECRET-guarded).",
        "security": [{ "cronSecret": [] }],
        "responses": {
          "200": { "description": "Sweep complete.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CronRunResult" } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" }
        }
      }
    },
    "/api/stripe-webhook-abandonment": {
      "post": {
        "tags": ["Webhooks"],
        "summary": "Stripe webhook — checkout abandonment capture + recovery cancellation.",
        "description": "Consumes checkout.session.expired, checkout.session.async_payment_failed, customer.subscription.created. STRIPE_WEBHOOK_SECRET_ABANDONMENT signs requests (Stripe-Signature header).",
        "security": [{ "stripeSignature": [] }],
        "parameters": [
          { "in": "header", "name": "Stripe-Signature", "required": true, "schema": { "type": "string" }, "description": "Stripe signature header (t=, v1=)." }
        ],
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/StripeWebhookEvent" } } } },
        "responses": {
          "200": { "description": "Acknowledged." },
          "400": { "description": "Missing or invalid signature." },
          "500": { "$ref": "#/components/responses/ServerError" }
        }
      },
      "get": {
        "tags": ["Webhooks"],
        "summary": "Probe — returns 405 in production; used by /api/health to verify routing.",
        "responses": { "405": { "description": "Method not allowed." } }
      }
    },
    "/api/referral": {
      "get": {
        "tags": ["Referrals"],
        "summary": "Resolve a referral code to its referrer.",
        "parameters": [
          { "in": "query", "name": "code", "required": true, "schema": { "type": "string", "minLength": 4, "maxLength": 12 }, "description": "Public referral code (e.g. GUST7A)." }
        ],
        "responses": {
          "200": { "description": "Resolved.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ReferralResolve" } } } },
          "400": { "$ref": "#/components/responses/BadRequest" }
        }
      },
      "post": {
        "tags": ["Referrals"],
        "summary": "Track (on signup) or credit (Stripe webhook). Action dispatched via ?action= or sub-path.",
        "parameters": [
          { "in": "query", "name": "action", "schema": { "type": "string", "enum": ["track", "credit"] }, "description": "Required when not using /api/referral/track or /api/referral/credit." }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "oneOf": [{ "$ref": "#/components/schemas/ReferralTrackPayload" }, { "$ref": "#/components/schemas/ReferralCreditPayload" }] }
            }
          }
        },
        "responses": {
          "200": { "description": "Linked (track) or credit applied.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OkResponse" } } } },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "500": { "$ref": "#/components/responses/ServerError" }
        }
      },
      "options": { "summary": "CORS preflight.", "tags": ["Referrals"], "responses": { "204": { "description": "No content." } } }
    },
    "/api/unsubscribe": {
      "post": {
        "tags": ["Email lifecycle"],
        "summary": "One-click List-Unsubscribe (RFC 8058) — Gmail/Yahoo Feb 2024 mandate.",
        "description": "Idempotent. No CSRF check — RFC 8058 forbids it because mailbox proxies legitimately POST. Persists to email_unsubscribes(email, channel, unsubscribed_at).",
        "parameters": [
          { "in": "query", "name": "e", "required": true, "schema": { "type": "string", "format": "email" }, "description": "URL-encoded email address." },
          { "in": "query", "name": "k", "schema": { "type": "string", "default": "all" }, "description": "Channel/kind (welcome | lead_magnet | close_r2 | rfp_send | ...). Defaults to all." }
        ],
        "responses": { "200": { "description": "Recorded.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OkResponse" } } } } }
      },
      "get": {
        "tags": ["Email lifecycle"],
        "summary": "Browser fallback — renders an HTML confirmation page.",
        "parameters": [
          { "in": "query", "name": "e", "required": true, "schema": { "type": "string", "format": "email" } },
          { "in": "query", "name": "k", "schema": { "type": "string" } }
        ],
        "responses": { "200": { "description": "HTML confirmation.", "content": { "text/html": { "schema": { "type": "string" } } } } }
      }
    },
    "/api/consent-log": {
      "post": {
        "tags": ["Email lifecycle"],
        "summary": "Record a cookie-consent decision for GDPR Art. 7(1) auditability.",
        "description": "Called by /assets/cookie-consent.js on every saveAndApply (accept_all / reject_all / manage_save / withdraw). Returns 204. Failures must be silent on the client. IP is hashed daily-rotated salt (CONSENT_IP_SALT_TODAY). No email / user_id / name persisted.",
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ConsentLogPayload" } } } },
        "responses": {
          "204": { "description": "Recorded (no content)." },
          "400": { "$ref": "#/components/responses/BadRequest" }
        }
      }
    },
    "/api/bing-indexnow": {
      "post": {
        "tags": ["SEO ops"],
        "summary": "Submit URLs to the IndexNow protocol (Bing, Yandex, Seznam).",
        "description": "Internal-only — guarded by INDEXNOW_INTERNAL_TOKEN bearer.",
        "security": [{ "internalBearer": [] }],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": { "urlList": { "type": "array", "items": { "type": "string", "format": "uri" }, "minItems": 1, "maxItems": 10000 } },
                "required": ["urlList"]
              }
            }
          }
        },
        "responses": {
          "200": { "description": "Submitted.", "content": { "application/json": { "schema": { "type": "object", "properties": { "ok": { "type": "boolean" }, "submitted": { "type": "integer" }, "indexNowStatus": { "type": "integer" } }, "required": ["ok", "submitted", "indexNowStatus"] } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" }
        }
      },
      "options": { "summary": "CORS preflight.", "tags": ["SEO ops"], "responses": { "204": { "description": "No content." } } }
    },
    "/api/pdf-whitelabel": {
      "post": {
        "tags": ["Export"],
        "summary": "Stream a white-label PDF (brief | rfp | comparison) using the caller's agency_branding profile.",
        "description": "Agency-tier only. Hand-rolled PDF 1.4 writer — zero npm deps because Cloudflare Pages Functions in this repo ship fetch-only. Returns binary PDF with Content-Disposition: attachment.",
        "security": [{ "bearerAuth": [] }],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "kind": { "type": "string", "enum": ["brief", "rfp", "comparison"] },
                  "client_name": { "type": "string" },
                  "client_logo_url": { "type": "string", "format": "uri" },
                  "payload": { "type": "object", "description": "Type-specific data (brief fields / RFP body / comparison rows)." }
                },
                "required": ["kind", "client_name"]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "PDF stream.",
            "content": { "application/pdf": { "schema": { "type": "string", "format": "binary" } } },
            "headers": {
              "Content-Disposition": { "schema": { "type": "string" }, "example": "attachment; filename=\"rfp-acme.pdf\"" }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "description": "Caller is not on Agency tier." }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "JWT",
        "description": "Supabase-issued JWT. Pass as Authorization: Bearer <token>."
      },
      "cronSecret": {
        "type": "apiKey",
        "in": "header",
        "name": "Authorization",
        "description": "CRON_SECRET shared with Cloudflare cron triggers. Format: Bearer <secret>."
      },
      "stripeSignature": {
        "type": "apiKey",
        "in": "header",
        "name": "Stripe-Signature",
        "description": "HMAC-SHA256 signature header generated by Stripe. Verified server-side against STRIPE_WEBHOOK_SECRET_ABANDONMENT (or sibling secret per endpoint)."
      },
      "webhookSignature": {
        "type": "apiKey",
        "in": "header",
        "name": "X-Webhook-Signature",
        "description": "Generic HMAC-SHA256 signature (hex digest) for non-Stripe webhook receivers."
      },
      "internalBearer": {
        "type": "http",
        "scheme": "bearer",
        "description": "Internal bearer token (INDEXNOW_INTERNAL_TOKEN, etc.). Not user-facing."
      }
    },
    "responses": {
      "BadRequest": { "description": "Validation failed.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } },
      "Unauthorized": { "description": "Missing or invalid credentials.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } },
      "RateLimited": { "description": "Per-IP rate limit exceeded.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } },
      "PayloadTooLarge": { "description": "Body exceeds the endpoint limit.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } },
      "ServerError": { "description": "Upstream (Resend / Supabase / Stripe) or internal failure.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } }
    },
    "schemas": {
      "OkResponse": {
        "type": "object",
        "properties": { "ok": { "type": "boolean", "const": true } },
        "required": ["ok"]
      },
      "OkWithId": {
        "type": "object",
        "properties": { "ok": { "type": "boolean", "const": true }, "id": { "type": "string" } },
        "required": ["ok", "id"]
      },
      "ErrorResponse": {
        "type": "object",
        "properties": { "ok": { "type": "boolean", "const": false }, "error": { "type": "string" } },
        "required": ["ok", "error"]
      },
      "HealthReport": {
        "type": "object",
        "properties": {
          "overall": { "type": "boolean" },
          "version": { "type": "string" },
          "checked_at": { "type": "string", "format": "date-time" },
          "checks": {
            "type": "object",
            "properties": {
              "supabase": { "$ref": "#/components/schemas/HealthCheck" },
              "stripe": { "type": "object", "additionalProperties": { "$ref": "#/components/schemas/HealthCheck" } },
              "crons": { "type": "object", "additionalProperties": { "$ref": "#/components/schemas/HealthCheck" } }
            }
          }
        },
        "required": ["overall", "checks"]
      },
      "HealthCheck": {
        "type": "object",
        "properties": {
          "ok": { "type": "boolean" },
          "ms": { "type": "integer" },
          "info": { "type": "string" }
        },
        "required": ["ok"]
      },
      "ContactPayload": {
        "type": "object",
        "properties": {
          "name": { "type": "string", "maxLength": 200 },
          "email": { "type": "string", "format": "email" },
          "company": { "type": "string", "maxLength": 200 },
          "interest": { "type": "string", "maxLength": 200 },
          "message": { "type": "string", "maxLength": 5000 },
          "gotcha": { "type": "string", "description": "Honeypot — must be empty." }
        },
        "required": ["email"]
      },
      "LeadMagnetPayload": {
        "type": "object",
        "properties": {
          "email": { "type": "string", "format": "email" },
          "source": { "type": "string", "description": "Free-form source tag (e.g. hotel-rfp-template-free-download)." },
          "template": { "type": "string", "enum": ["hotel-rfp", "migration-checklist"] },
          "utm": { "type": "object", "additionalProperties": { "type": "string" } },
          "gotcha": { "type": "string", "description": "Honeypot — must be empty." }
        },
        "required": ["email", "source", "template"]
      },
      "RfpAuditPayload": {
        "type": "object",
        "properties": {
          "email": { "type": "string", "format": "email" },
          "rfp_snippet": { "type": "string", "maxLength": 100000 },
          "score": { "type": "number", "minimum": 0, "maximum": 100 },
          "consent": { "type": "boolean", "const": true }
        },
        "required": ["email", "score", "consent"]
      },
      "RfpAuditLeadMagnetPayload": {
        "type": "object",
        "properties": {
          "email": { "type": "string", "format": "email" },
          "consent": { "type": "boolean", "const": true },
          "score": { "type": "number", "minimum": 0, "maximum": 100 },
          "category_breakdown": { "type": "object", "additionalProperties": { "type": "number" } },
          "answers": { "type": "object", "additionalProperties": true }
        },
        "required": ["email", "consent", "score"]
      },
      "FoundingApplyPayload": {
        "type": "object",
        "properties": {
          "agency_name": { "type": "string", "maxLength": 200 },
          "contact_name": { "type": "string", "maxLength": 200 },
          "email": { "type": "string", "format": "email" },
          "phone": { "type": "string", "maxLength": 50 },
          "website": { "type": "string", "format": "uri" },
          "linkedin": { "type": "string", "format": "uri" },
          "country": { "type": "string", "maxLength": 80 },
          "team_size": { "type": "integer", "minimum": 1, "maximum": 10000 },
          "rfps_per_year": { "type": "integer", "minimum": 0, "maximum": 100000 },
          "why_founding": { "type": "string", "maxLength": 5000 }
        },
        "required": ["agency_name", "contact_name", "email", "website", "country"]
      },
      "FoundingApplyResponse": {
        "type": "object",
        "properties": {
          "ok": { "type": "boolean" },
          "id": { "type": "string" },
          "status": { "type": "string", "enum": ["auto-approved", "pending-review", "rejected"] },
          "score": { "type": "number", "minimum": 0, "maximum": 100 }
        },
        "required": ["ok", "id", "status"]
      },
      "FoundingCounter": {
        "type": "object",
        "properties": {
          "ok": { "type": "boolean" },
          "approved": { "type": "integer", "minimum": 0 },
          "pending": { "type": "integer", "minimum": 0 },
          "total": { "type": "integer", "minimum": 0 },
          "slots_remaining": { "type": "integer", "minimum": 0, "maximum": 100 }
        },
        "required": ["approved", "pending", "total", "slots_remaining"]
      },
      "CronRunResult": {
        "type": "object",
        "properties": {
          "ok": { "type": "boolean" },
          "scanned": { "type": "integer" },
          "sent": { "type": "integer" },
          "skipped": { "type": "integer" },
          "errors": { "type": "integer" },
          "ran_at": { "type": "string", "format": "date-time" }
        },
        "required": ["ok"]
      },
      "ReferralResolve": {
        "type": "object",
        "properties": {
          "ok": { "type": "boolean" },
          "referrer_user_id": { "type": "string", "format": "uuid", "nullable": true },
          "code": { "type": "string" },
          "valid": { "type": "boolean" }
        },
        "required": ["ok", "code", "valid"]
      },
      "ReferralTrackPayload": {
        "type": "object",
        "properties": {
          "code": { "type": "string" },
          "referred_user_id": { "type": "string", "format": "uuid" },
          "referred_email": { "type": "string", "format": "email" },
          "referred_ip": { "type": "string" }
        },
        "required": ["code", "referred_user_id", "referred_email"]
      },
      "ReferralCreditPayload": {
        "type": "object",
        "properties": {
          "referred_user_id": { "type": "string", "format": "uuid" },
          "stripe_customer_id": { "type": "string" },
          "invoice_id": { "type": "string" }
        },
        "required": ["referred_user_id", "stripe_customer_id", "invoice_id"]
      },
      "ConsentLogPayload": {
        "type": "object",
        "properties": {
          "consent_id": { "type": "string", "format": "uuid", "description": "Client-generated stable UUID — opaque." },
          "page_url": { "type": "string", "format": "uri" },
          "locale": { "type": "string", "maxLength": 10 },
          "analytics_granted": { "type": "boolean" },
          "marketing_granted": { "type": "boolean" },
          "functional_granted": { "type": "boolean" },
          "banner_version": { "type": "string" },
          "consent_action": { "type": "string", "enum": ["accept_all", "reject_all", "manage_save", "withdraw"] }
        },
        "required": ["consent_id", "consent_action", "banner_version"]
      },
      "StripeWebhookEvent": {
        "type": "object",
        "description": "Stripe Event object. Only the relevant fields are listed; full schema lives in Stripe's docs.",
        "properties": {
          "id": { "type": "string" },
          "type": { "type": "string", "enum": ["checkout.session.expired", "checkout.session.async_payment_failed", "customer.subscription.created"] },
          "data": { "type": "object", "properties": { "object": { "type": "object" } } }
        },
        "required": ["id", "type", "data"]
      }
    }
  }
}
