{
 "title": "SaaS starter production-readiness audit",
 "published": "2026-10-09",
 "publisher": "LaunchSaaS (launchsaas.dev)",
 "license": "CC BY 4.0",
 "summary": {
  "n": 17,
  "nw": 14,
  "sig": 14,
  "dup": 3,
  "dup_partial": 11,
  "rl": 7,
  "csp": 1,
  "notests": 6,
  "dep": 2
 },
 "method": "Static reading at a fixed commit; nothing installed or executed. Evidence paths are relative to the repository root.",
 "scored": {
  "apptension/saas-boilerplate": {
   "commit": "c1d99922560708e158f6aa7f48736bc50354f6fe",
   "billing": {
    "v": "yes",
    "provider": "Stripe (via dj-stripe 2.8.1)",
    "ev": "packages/backend/pyproject.toml:93 dj-stripe==2.8.1; packages/backend/apps/finances/urls.py:4 includes djstripe.urls (webhook endpoint); packages/backend/apps/finances/serializers.py:38 PaymentIntent create; packages/backend/apps/finances/webhooks.py:13-109 event handlers. Uses PaymentIntents/SubscriptionSchedules, not Stripe Checkout."
   },
   "webhook_signature": {
    "v": "yes",
    "ev": "library-managed: dj-stripe webhook view verifies Stripe-Signature with DJSTRIPE_WEBHOOK_SECRET (packages/backend/config/settings.py:537, default empty string); dj-stripe source is not vendored in the repo"
   },
   "webhook_duplicates": {
    "v": "yes",
    "ev": "library-managed: dj-stripe Event.process() returns early when an Event with that Stripe id already exists, so @webhooks.handler functions in packages/backend/apps/finances/webhooks.py are not re-invoked; behavior is in dj-stripe 2.8.1 (pyproject.toml:93), not in repo code",
    "on_duplicate": "dj-stripe finds the stored Event id and skips handlers, so the payment-failure email (webhooks.py:77-85) and free-plan schedule creation (webhooks.py:13-23) do not run twice."
   },
   "rate_limit": {
    "v": "yes",
    "ev": "packages/backend/config/settings.py:360-369 global DRF throttle classes (anon 60/min, user 300/min); packages/backend/apps/users/schema.py:41 @ratelimit 30/min on ObtainTokenMutation (login), :89 10/min (signup); packages/backend/apps/sso/views.py:122,288"
   },
   "tests": {
    "count": 258,
    "kind": "unit (pytest backend 55 + workers 1, jest webapp 38 + webapp-libs 160 + internal 4); no e2e",
    "ci": "yes",
    "ev": ".github/workflows/backend.yml:37 nx run backend:test; .github/workflows/webapp.yml:62,116 jest for webapp and libs"
   },
   "csp": {
    "v": "no",
    "ev": "packages/webapp/nginx.conf:54-58 sets X-Frame-Options, nosniff, X-XSS-Protection, Referrer-Policy but no Content-Security-Policy; no CSP in backend settings or CDK infra"
   },
   "dep_updates": {
    "v": "no",
    "ev": "no .github/dependabot.yml or renovate config"
   },
   "notes": "Signature + dedupe verdicts rest on dj-stripe library behavior (cited by pinned version, source not in repo). Test count is file count by name pattern (test_*.py, *.test.ts(x), *.spec.ts) excluding node_modules; rounded figure ~258."
  },
  "boxyhq/saas-starter-kit": {
   "commit": "abc9b686823cbfb4973c79bc36fea37a3244be6c",
   "billing": {
    "v": "yes",
    "provider": "Stripe",
    "ev": "pages/api/teams/[slug]/payments/create-checkout-session.ts:42 checkout.sessions.create (mode subscription); pages/api/webhooks/stripe.ts:36-79 webhook syncing customer.subscription.*"
   },
   "webhook_signature": {
    "v": "yes",
    "ev": "pages/api/webhooks/stripe.ts:46 stripe.webhooks.constructEvent(rawBody, sig, webhookSecret) with bodyParser disabled (:14-18)"
   },
   "webhook_duplicates": {
    "v": "partial",
    "ev": "models/subscription.ts:19 prisma.subscription.create keyed on Stripe subscription id (prisma/schema.prisma:149 id String @id); updates are update-by-id (models/subscription.ts:39-45); no event-id store",
    "on_duplicate": "A duplicate subscription.created fails the primary-key insert and returns 400 (stripe.ts:70), so no duplicate row but Stripe retries; duplicate updated/deleted events re-apply the same state."
   },
   "rate_limit": {
    "v": "partial",
    "ev": "no request rate limiting; per-account lockout after MAX_LOGIN_ATTEMPTS (default 5) failed passwords: lib/nextAuth.ts:89, lib/accountLock.ts:79, lib/env.ts:115"
   },
   "tests": {
    "count": 8,
    "kind": "unit 1 (__tests__/lib/server-common.spec.ts) + e2e 7 (Playwright tests/e2e/{auth,session,settings})",
    "ci": "yes",
    "ev": ".github/workflows/main.yml:80 npm run test; :87 npx playwright test"
   },
   "csp": {
    "v": "partial",
    "ev": "middleware.ts:18 generateCSP, :125-127 sets Content-Security-Policy response header only if env.securityHeadersEnabled (lib/env.ts:7 SECURITY_HEADERS_ENABLED, defaults false; not in .env.example)"
   },
   "dep_updates": {
    "v": "yes",
    "ev": ".github/dependabot.yml (npm, daily)"
   },
   "notes": "CSP is implemented but opt-in and off by default. Lockout is per-account, not IP throttling. pages/api/webhooks/stripe.ts:43-44 returns without sending a response when signature or secret is missing (request hangs/times out rather than bypassing verification)."
  },
  "coremvp/Hikari": {
   "commit": "b171e7fd1f1ee422d233ca532203b43cf7e78607",
   "billing": {
    "v": "yes",
    "provider": "Stripe",
    "ev": "src/providers/stripe.ts:148 checkout.sessions.create; src/api/billing.ts:31-37 POST /webhooks/stripe; src/services/billing.ts:135-164 webhook sync"
   },
   "webhook_signature": {
    "v": "yes",
    "ev": "src/providers/stripe.ts:183 webhooks.constructEventAsync with required STRIPE_WEBHOOK_SECRET (:180 z.string().min(1)), plus livemode check (:191-193)"
   },
   "webhook_duplicates": {
    "v": "partial",
    "ev": "src/services/billing.ts:142-150 under a pg_advisory_xact_lock per subscription (src/repositories/billing.ts:25-35) re-fetches current subscription from Stripe and upserts by subscription id (src/repositories/billing.ts:74-78 onConflictDoUpdate); no event-id store",
    "on_duplicate": "The subscription is re-fetched from Stripe and upserted to the same row; there are no emails/credits or other side effects, so a duplicate is harmless."
   },
   "rate_limit": {
    "v": "no",
    "ev": "no throttle code in src/; src/proxy.ts only refreshes Supabase session; supabase/config.toml has no rate-limit settings (Supabase-hosted auth applies its own limits outside the repo)"
   },
   "tests": {
    "count": 5,
    "kind": "unit 3 (bun test: tests/auth-continuation, billing, blog-indexability) + e2e 2 (Playwright e2e/auth.spec.ts, subscription.spec.ts)",
    "ci": "no",
    "ev": "no .github directory"
   },
   "csp": {
    "v": "no",
    "ev": "next.config.mjs:3 only poweredByHeader:false; vercel.json has no headers; src/proxy.ts sets only Cache-Control"
   },
   "dep_updates": {
    "v": "no",
    "ev": "no dependabot/renovate config"
   },
   "notes": "Dedupe marked partial for consistency with the rubric (no event-id store), but this is the strongest pattern in the batch: lock + re-fetch + upsert, no side effects. A maintainer could fairly argue 'yes'."
  },
  "electrikhq/electrik": {
   "commit": "d0efd81bdb72bba3c6305cf15e690cec4e310c4d",
   "billing": {
    "v": "yes",
    "provider": "Stripe (via Laravel Cashier)",
    "ev": "composer.json:35 laravel/cashier ^15.0; src/ElectrikServiceProvider.php:127-128 Cashier::useCustomerModel(Team); src/Actions/Billing/CreateSubscription.php:39 newSubscription()->checkout(); Cashier registers its /stripe/webhook route (no Cashier::ignoreRoutes in src)"
   },
   "webhook_signature": {
    "v": "yes",
    "ev": "library-managed: Cashier's WebhookController applies VerifyWebhookSignature when cashier.webhook.secret / STRIPE_WEBHOOK_SECRET is set; installer writes STRIPE_WEBHOOK_SECRET (src/Console/InstallCommand.php:966) and the billing status page flags it if missing (src/Support/BillingStatus.php:199-202)"
   },
   "webhook_duplicates": {
    "v": "partial",
    "ev": "src/Listeners/LogStripeWebhook.php:48-61 records events by stripe_id but only for logging (does not skip processing); stripe_webhook_events.stripe_id is indexed, not unique (database/migrations/2026_12_31_000071_create_stripe_webhook_events_table.php:17); state sync is Cashier's built-in handlers keyed by subscription stripe_id",
    "on_duplicate": "Cashier re-applies the subscription state for the same stripe_id (no duplicate row) and Electrik updates the existing log row; no extra side effects are added by Electrik."
   },
   "rate_limit": {
    "v": "yes",
    "ev": "src/Livewire/Auth/Login.php:206 RateLimiter::tooManyAttempts(throttleKey, 5); routes/auth.php:28,45 throttle:6,1; routes/api.php:7 throttle:api; src/ElectrikServiceProvider.php:211-213 pushes throttle:api onto api group"
   },
   "tests": {
    "count": 2,
    "kind": "unit/feature (PHPUnit: tests/Unit/OnboardingSupportTest.php, tests/Feature/ConfigTest.php)",
    "ci": "yes",
    "ev": ".github/workflows/ci.yml:31-34 -> scripts/ci-package.sh:15 phpunit; scripts/ci-sandbox.sh:35 runs tests in a separate electrik-sandbox repo not included here"
   },
   "csp": {
    "v": "no",
    "ev": "no Content-Security-Policy in src/, config/, routes/, stubs/"
   },
   "dep_updates": {
    "v": "no",
    "ev": "no dependabot/renovate config"
   },
   "notes": "Electrik is a Composer package installed into a host Laravel app, so CSP/dependabot arguably belong to the host app. Signature and dedupe rely on Laravel Cashier behavior (not vendored)."
  },
  "eriktaveras/django-saas-boilerplate": {
   "commit": "4cac15bd4991a013e03d826b197ffaee3ea8a92c",
   "billing": {
    "v": "yes",
    "provider": "Stripe",
    "ev": "apps/subscriptions/views.py:133 stripe.Subscription.create (Elements/PaymentIntent flow, not Stripe Checkout); apps/subscriptions/views.py:179-212 webhook; apps/subscriptions/urls.py:10"
   },
   "webhook_signature": {
    "v": "yes",
    "ev": "apps/subscriptions/views.py:190 stripe.Webhook.construct_event; :180-184 refuses (500) when STRIPE_WEBHOOK_SECRET unset"
   },
   "webhook_duplicates": {
    "v": "partial",
    "ev": "apps/subscriptions/views.py:198-210 overwrites subscription_status by stripe_subscription_id and re-syncs access (views.py:38-50 get_or_create); no event-id store",
    "on_duplicate": "The same status is written again to StripeCustomer and UserSettings; no emails or other side effects."
   },
   "rate_limit": {
    "v": "yes",
    "ev": "library default: django-allauth >=65 (requirements.txt) mounted at core/urls.py:27 with allauth backend core/settings.py:197; allauth's default ACCOUNT_RATE_LIMITS (login failures, signup, password reset) apply since core/settings.py does not override them. No explicit throttle config in repo."
   },
   "tests": {
    "count": 4,
    "kind": "unit (Django TestCase: apps/{accounts,dashboard,landing,subscriptions}/tests.py, 46 test methods)",
    "ci": "no",
    "ev": "no .github directory"
   },
   "csp": {
    "v": "yes",
    "ev": "core/settings.py:107 django.middleware.csp.ContentSecurityPolicyMiddleware; core/settings.py:242-252 SECURE_CSP (Django 6 built-in)"
   },
   "dep_updates": {
    "v": "no",
    "ev": "no dependabot/renovate config"
   },
   "notes": "Rate-limit 'yes' rests on allauth's on-by-default limits, not repo config; a reviewer could call it partial."
  },
  "ernestofgonzalez/djangorocket": {
   "commit": "d0fd85d720db8af17cf9ecfa9d8ea28e18a4bf85",
   "billing": {
    "v": "partial",
    "provider": "Stripe",
    "ev": "Judged generated template djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/ (paths below relative to its src/{{ cookiecutter.project_slug }}/). billing/utils.py:15-23 creates Customer + trial Subscription at signup via API; billing/views.py:60 checkout.Session.create(mode='setup') only collects a card; billing/views.py:143-182 webhook handles only checkout.session.completed and ends in a TODO comment (:178-180); no subscription status sync from webhooks"
   },
   "webhook_signature": {
    "v": "yes",
    "ev": "billing/views.py:151 stripe.Webhook.construct_event(payload, sig_header, webhook_secret)"
   },
   "webhook_duplicates": {
    "v": "partial",
    "ev": "billing/views.py:156-180 sets the customer's default payment method from the setup intent; no event-id store",
    "on_duplicate": "The same default payment method is set again on the Stripe customer; idempotent only because the handler does almost nothing."
   },
   "rate_limit": {
    "v": "no",
    "ev": "no django-ratelimit/axes in requirements/requirements.txt; settings.py:144-156 REST_FRAMEWORK has no DEFAULT_THROTTLE_CLASSES; custom auth/views.py login has no throttle"
   },
   "tests": {
    "count": 3,
    "kind": "unit (generated project: auth/tests/views/{login,logout,register}_view/tests.py, 31 tests). Repo also has 9 generator tests under tests/ (not counted)",
    "ci": "yes",
    "ev": ".github/workflows/tests.yml:41 pytest; :75 pytest --run-e2e, where tests/e2e/test_generated_suite.py runs the generated project's own suite"
   },
   "csp": {
    "v": "no",
    "ev": "settings.py:36-40 sets nosniff/XSS filter/SSL redirect only; no CSP middleware or django-csp"
   },
   "dep_updates": {
    "v": "no",
    "ev": "no dependabot/renovate config"
   },
   "notes": "Billing is scaffolding, not a working subscription lifecycle (no handling of subscription updated/deleted or failed payments). stripe==4.2.0 is pinned (old)."
  },
  "fogbender/b2b-saaskit": {
   "commit": "148385ddf8a0cc4820cb6de9c1c46fd6092851af",
   "billing": {
    "v": "yes",
    "provider": "Stripe",
    "ev": "src/pages/api/create-checkout-session.ts:42 checkout.sessions.create (mode subscription, orgId metadata); src/lib/stripe.ts:22-55 reads subscription state live via stripe.subscriptions.search (no webhooks); src/lib/trpc/routers/settings.ts:15-21"
   },
   "webhook_signature": {
    "v": "n/a",
    "ev": "no webhook endpoint; subscription state is queried from the Stripe API on demand (src/lib/stripe.ts:28)"
   },
   "webhook_duplicates": {
    "v": "n/a",
    "ev": "no webhooks",
    "on_duplicate": "n/a (no webhook handler)"
   },
   "rate_limit": {
    "v": "partial",
    "ev": "src/lib/trpc/routers/prompts.ts:327 per-user daily quota (rateLimitUpsert, shared_key_ratelimit table src/db/0000_yummy_ben_parker.sql:51) on use of the shared demo OpenAI key only; no throttling of auth or other API routes (auth is hosted PropelAuth)"
   },
   "tests": {
    "count": 0,
    "kind": "none",
    "ci": "no",
    "ev": "no test files; workflows run only lint/typecheck/prettier (.github/workflows/lint.yml:12, typescript.yml:12, prettier.yml:10)"
   },
   "csp": {
    "v": "no",
    "ev": "no CSP in astro.config.mjs, no middleware, no headers config"
   },
   "dep_updates": {
    "v": "no",
    "ev": "no dependabot/renovate config"
   },
   "notes": "Webhook-free design (live Stripe Search API) makes signature/duplicate checks n/a, which is defensible but means no server-side record of subscriptions. Rate limit is a product quota, not abuse protection."
  },
  "joachimchauvet/modernstack-saas": {
   "commit": "a087c93d2c7af52b365cd8e86da5a37eb0ba2aad",
   "billing": {
    "v": "yes",
    "provider": "Autumn (hosted billing layer on Stripe)",
    "ev": "src/convex/billing.ts:135-151 attach action -> autumn.billing.attach (Stripe checkout via Autumn); src/convex/billing.ts:195 autumn.check; autumn.config.ts plans; state lives in Autumn, queried live"
   },
   "webhook_signature": {
    "v": "n/a",
    "ev": "no payment webhook endpoint in repo (src/convex/http.ts:6 registers only auth routes); Stripe webhooks are received by Autumn's hosted service"
   },
   "webhook_duplicates": {
    "v": "n/a",
    "ev": "no payment webhooks in repo",
    "on_duplicate": "n/a (handled by Autumn, outside repo)"
   },
   "rate_limit": {
    "v": "yes",
    "ev": "src/convex/convex.config.ts:4,8 @convex-dev/rate-limiter; applied in src/convex/waitlist.ts:95-96 (per-IP and global waitlist join), :128, :173. Auth endpoints (Better Auth) have no explicit rateLimit config in src/convex/auth.ts"
   },
   "tests": {
    "count": 1,
    "kind": "e2e (Playwright smoke: e2e/demo.test.ts checks h1 visible)",
    "ci": "no",
    "ev": "workflows run lint/check/build/deploy only (.github/workflows/code-quality.yml:36,39), no test step"
   },
   "csp": {
    "v": "no",
    "ev": "svelte.config.js has no kit.csp; src/hooks.server.ts sets no headers; no static/_headers"
   },
   "dep_updates": {
    "v": "no",
    "ev": "no dependabot/renovate config"
   },
   "notes": "Billing is delegated to Autumn, so webhook verification/idempotency are the vendor's responsibility; n/a is fair, not a fail. Rate limiting covers the public waitlist only."
  },
  "KolbySisk/next-supabase-stripe-starter": {
   "commit": "9839fd47d282b1b41a80fb82efea80ac7d1080bc",
   "billing": {
    "v": "yes",
    "provider": "Stripe",
    "ev": "src/features/pricing/actions/create-checkout-action.ts:30 (checkout); src/app/api/webhooks/route.ts:20-77 (webhook sync)"
   },
   "webhook_signature": {
    "v": "yes",
    "ev": "src/app/api/webhooks/route.ts:28 stripeAdmin.webhooks.constructEvent"
   },
   "webhook_duplicates": {
    "v": "partial",
    "ev": "src/features/account/controllers/upsert-user-subscription.ts:28,50 (re-fetches subscription from Stripe, upserts by subscription id); no event-id store",
    "on_duplicate": "Subscription row is re-fetched and upserted to the same state; for checkout.session.completed the billing-details copy to the Stripe customer and users table runs again (idempotent overwrite, no emails/credits)."
   },
   "rate_limit": {
    "v": "no",
    "ev": "no throttle code in repo; src/middleware.ts only refreshes Supabase session"
   },
   "tests": {
    "count": 0,
    "kind": "none",
    "ci": "no",
    "ev": "no test files; no .github directory"
   },
   "csp": {
    "v": "no",
    "ev": "next.config.js:2 empty config; src/middleware.ts sets no headers"
   },
   "dep_updates": {
    "v": "no",
    "ev": "no dependabot/renovate config"
   },
   "notes": "Auth is Supabase-hosted; Supabase Auth applies its own server-side rate limits outside the repo. route.ts:27 returns undefined (not a 400) when signature/secret missing, which errors in Next.js rather than bypassing verification."
  },
  "mickasmt/next-saas-stripe-starter": {
   "commit": "c6df229ae73c207e87356d5e592779c511a9b56d",
   "billing": {
    "v": "yes",
    "provider": "Stripe (via @better-auth/stripe plugin)",
    "ev": "lib/auth/server.ts:131-168; package.json:22"
   },
   "webhook_signature": {
    "v": "yes",
    "ev": "library: @better-auth/stripe (package.json:22) owns the /api/auth/stripe/webhook endpoint and verifies with the configured stripeWebhookSecret (lib/auth/server.ts:133). Repo has no custom webhook handler."
   },
   "webhook_duplicates": {
    "v": "partial",
    "ev": "handled inside @better-auth/stripe (updates subscription row keyed by Stripe subscription/reference id); repo onEvent hook only revalidates a cache tag (lib/auth/server.ts:135-142); modules/billing/sync.ts:45 also reconciles against live Stripe on billing page load",
    "on_duplicate": "Plugin re-applies the same subscription state; the repo's only custom side effect is a cache revalidation, so nothing user-visible repeats (no event-id store)."
   },
   "rate_limit": {
    "v": "yes",
    "ev": "better-auth built-in rate limiter, enabled by default in production (package.json:24 better-auth; not disabled in lib/auth/server.ts:28). Library default, not explicitly configured."
   },
   "tests": {
    "count": 0,
    "kind": "none",
    "ci": "no",
    "ev": "no test files; .github/workflows/prune.yml only prunes modules and builds"
   },
   "csp": {
    "v": "no",
    "ev": "next.config.ts:12 contentSecurityPolicy applies only to next/image SVG responses, not the app; no headers()/middleware CSP"
   },
   "dep_updates": {
    "v": "no",
    "ev": "no dependabot/renovate config"
   },
   "notes": "Signature, duplicates and rate-limit verdicts rely on better-auth library defaults (not inspected in node_modules; not installed). better-auth default rate-limit storage is in-memory, so per-instance on serverless. With STRIPE_WEBHOOK_SECRET unset the secret is '' (server.ts:133)."
  },
  "nextacular/nextacular": {
   "commit": "06bee752a0423faa2ba0217ea2fedd719a52bda9",
   "billing": {
    "v": "partial",
    "provider": "Stripe",
    "ev": "src/pages/api/payments/subscription/[priceId].ts:37-48 (checkout, metadata on the Checkout Session); src/pages/api/payments/hooks.ts:44-52 (only handles charge.succeeded, reading metadata from the Charge)"
   },
   "webhook_signature": {
    "v": "yes",
    "ev": "src/pages/api/payments/hooks.ts:21-38 (refuses if secret unset, then stripe.webhooks.constructEvent)"
   },
   "webhook_duplicates": {
    "v": "partial",
    "ev": "prisma/services/customer.ts:23-30 sets subscriptionType by customerId",
    "on_duplicate": "Sets the same subscriptionType again; no other side effects."
   },
   "rate_limit": {
    "v": "no",
    "ev": "no throttle code; src/lib/server/init-middleware.ts is a generic wrapper, unused for rate limiting"
   },
   "tests": {
    "count": 0,
    "kind": "none",
    "ci": "no",
    "ev": ".github/workflows/ci.yml runs lint, typecheck, build only; no test files"
   },
   "csp": {
    "v": "no",
    "ev": "next.config.js:1-6 no headers; src/middleware.ts only rewrites"
   },
   "dep_updates": {
    "v": "no",
    "ev": "no dependabot/renovate config"
   },
   "notes": "Billing marked partial: checkout sets metadata on the Checkout Session (subscription/[priceId].ts:44-47), but the webhook reads metadata from charge.succeeded (hooks.ts:40-50); Stripe does not copy Checkout Session metadata onto Charges, so the plan update likely never fires. No cancellation/subscription.deleted handling. hooks.ts:54 sends 400 for other event types then falls through to res.status(200) at :58 (headers-already-sent error). Maintainer could dispute the metadata point; it is based on Stripe's documented behavior, not a test run."
  },
  "nextify-limited/saasfly": {
   "commit": "49f7e28f69eae9cd9eed84221e13a3dbae87da67",
   "billing": {
    "v": "yes",
    "provider": "Stripe",
    "ev": "packages/api/src/router/stripe.ts:73 (checkout), apps/nextjs/src/app/api/webhooks/stripe/route.ts + packages/stripe/src/webhooks.ts (sync)"
   },
   "webhook_signature": {
    "v": "yes",
    "ev": "apps/nextjs/src/app/api/webhooks/stripe/route.ts:11 stripe.webhooks.constructEvent"
   },
   "webhook_duplicates": {
    "v": "partial",
    "ev": "packages/stripe/src/webhooks.ts:29-37,69-81 update Customer row with values re-fetched from Stripe; no event-id store",
    "on_duplicate": "Customer row is overwritten with the same subscription/price/period values; no other side effects."
   },
   "rate_limit": {
    "v": "no",
    "ev": "no throttle code in repo"
   },
   "tests": {
    "count": 0,
    "kind": "none",
    "ci": "no",
    "ev": ".github/workflows/ci.yml runs build/lint/format/typecheck only"
   },
   "csp": {
    "v": "no",
    "ev": "apps/nextjs/next.config.mjs and apps/nextjs/src/middleware.ts set no CSP; vercel.json has no headers"
   },
   "dep_updates": {
    "v": "no",
    "ev": "no dependabot/renovate config"
   },
   "notes": "customer.subscription.updated is a console.log stub (webhooks.ts:84-87) and customer.subscription.deleted is not handled, so cancellations are not synced. Webhook route also exports GET (route.ts:27)."
  },
  "nextjs/saas-starter": {
   "commit": "6e33e58b1e553a41fe22e6b941a7229a002de361",
   "billing": {
    "v": "yes",
    "provider": "Stripe",
    "ev": "lib/payments/stripe.ts:27 (checkout); app/api/stripe/webhook/route.ts:23-31 + lib/payments/stripe.ts:117-147 (sync)"
   },
   "webhook_signature": {
    "v": "yes",
    "ev": "app/api/stripe/webhook/route.ts:14 stripe.webhooks.constructEvent"
   },
   "webhook_duplicates": {
    "v": "partial",
    "ev": "lib/payments/stripe.ts:131-145 updateTeamSubscription sets status/ids from the event payload; no event-id store",
    "on_duplicate": "Team subscription fields are set to the same values again; no other side effects (but an older duplicate delivered late could overwrite newer state, since it uses the event payload not a fresh fetch)."
   },
   "rate_limit": {
    "v": "no",
    "ev": "no throttle on sign-in/sign-up actions (app/(login)/actions.ts) or API routes"
   },
   "tests": {
    "count": 0,
    "kind": "none",
    "ci": "no",
    "ev": "no test files; no .github directory"
   },
   "csp": {
    "v": "no",
    "ev": "next.config.ts:3-8 no headers; middleware.ts sets no CSP"
   },
   "dep_updates": {
    "v": "no",
    "ev": "no dependabot/renovate config"
   },
   "notes": "Official Vercel/Next.js example; deliberately minimal."
  },
  "scosman/CMSaasStarter": {
   "commit": "2e61406c0764585031b40e657ebebf50187c8429",
   "billing": {
    "v": "yes",
    "provider": "Stripe (no webhooks; live API lookup)",
    "ev": "src/routes/(admin)/account/subscribe/[slug]/+page.server.ts:47 (checkout); src/routes/(admin)/account/subscription_helpers.server.ts:85 (stripe.subscriptions.list on demand as the sync mechanism)"
   },
   "webhook_signature": {
    "v": "n/a",
    "ev": "no webhook endpoint; subscription state is read live from Stripe API"
   },
   "webhook_duplicates": {
    "v": "n/a",
    "ev": "no webhook endpoint",
    "on_duplicate": "n/a (no webhooks consumed)"
   },
   "rate_limit": {
    "v": "no",
    "ev": "no throttle code in src/hooks.server.ts or routes"
   },
   "tests": {
    "count": 3,
    "kind": "unit (vitest)",
    "ci": "yes",
    "ev": "src/index.test.ts, src/lib/mailer.test.ts, src/routes/(admin)/account/api/page.server.test.ts; .github/workflows/tests.yml:22-23 npm run test_run"
   },
   "csp": {
    "v": "no",
    "ev": "svelte.config.js:5-14 no kit.csp; hooks.server.ts sets no CSP"
   },
   "dep_updates": {
    "v": "no",
    "ev": "no dependabot/renovate config"
   },
   "notes": "No-webhook design is intentional (avoids sync bugs, costs a Stripe API call per page load). Supabase-hosted auth applies provider-side rate limits outside the repo. Should be scored n/a for webhook rows, not 'no'."
  },
  "thedevdojo/wave": {
   "commit": "d5f7ed2f96c809b7b62d1ea61389d933c2265e03",
   "billing": {
    "v": "yes",
    "provider": "Stripe and Paddle",
    "ev": "wave/src/Http/Livewire/Billing/Checkout.php:49 (Stripe checkout); wave/routes/web.php:46-47 (Paddle + Stripe webhooks)"
   },
   "webhook_signature": {
    "v": "yes",
    "ev": "Stripe: wave/src/Http/Controllers/Billing/Webhooks/StripeWebhook.php:28 Webhook::constructEvent. Paddle: HMAC hash_equals in wave/src/Http/Middleware/VerifyPaddleWebhookSignature.php:58-67, applied at wave/routes/web.php:46"
   },
   "webhook_duplicates": {
    "v": "yes",
    "ev": "StripeWebhook.php:98-116 dedupe by checkout session id (cache key + existing-subscription check) backed by unique(vendor_slug, vendor_subscription_id) in wave/database/migrations/2024_03_29_230313_create_subscriptions_table.php:31",
    "on_duplicate": "A repeated checkout.session.completed returns early without creating a second subscription; subscription.updated/deleted re-apply the same state."
   },
   "rate_limit": {
    "v": "yes",
    "ev": "bootstrap/app.php:38 throttleApi(); app/Providers/AppServiceProvider.php:94-96 60/min per user/IP on api group (covers API login/register in wave/routes/api.php via routes/api.php Wave::api())"
   },
   "tests": {
    "count": 23,
    "kind": "feature + unit (Pest)",
    "ci": "yes",
    "ev": "tests/Feature/*Test.php (22) + tests/Unit/PluginAutoloaderTest.php; .github/workflows/tests.yml:80-83 ./vendor/bin/pest"
   },
   "csp": {
    "v": "no",
    "ev": "no Content-Security-Policy in app/, wave/, config/, bootstrap/"
   },
   "dep_updates": {
    "v": "no",
    "ev": "no dependabot/renovate config"
   },
   "notes": "Web (session) login comes from vendor package devdojo/auth, not inspected. Paddle cancel handler (PaddleWebhook.php:41-42) calls ->cancel() on a possibly-null result, so a duplicate subscription.canceled returns a 500 (state already canceled). Cache-based dedupe check-then-put is not atomic, but the DB unique constraint is the backstop."
  },
  "wasp-lang/open-saas": {
   "commit": "e60f84b7d17fabec2297f46ae1e18444f0c9cbbf",
   "billing": {
    "v": "yes",
    "provider": "Stripe (default), Lemon Squeezy and Polar alternatives",
    "ev": "template/app/src/payment/paymentProcessor.ts:37; template/app/src/payment/stripe/checkoutUtils.ts:37; template/app/src/payment/stripe/webhook.ts"
   },
   "webhook_signature": {
    "v": "yes",
    "ev": "Stripe: template/app/src/payment/stripe/webhook.ts:76 constructEvent; Lemon Squeezy: lemonSqueezy/webhook.ts:90-94 HMAC + timingSafeEqual; Polar: polar/webhook.ts:44 validateEvent"
   },
   "webhook_duplicates": {
    "v": "yes",
    "ev": "template/app/src/payment/processedWebhookEvent.ts:21-63 records event id first (unique constraint, template/app/schema.prisma:35), used at stripe/webhook.ts:38, polar/webhook.ts:50, lemonSqueezy/webhook.ts:38",
    "on_duplicate": "Insert of the event id hits the unique constraint and the handler is skipped, so credits/subscription updates are not applied twice."
   },
   "rate_limit": {
    "v": "no",
    "ev": "no rate-limit middleware in template/app (grep for ratelimit/express-rate-limit/helmet empty)"
   },
   "tests": {
    "count": 4,
    "kind": "e2e (Playwright)",
    "ci": "yes",
    "ev": "template/e2e-tests/tests/*.spec.ts (4); .github/workflows/e2e-tests.yml:123-126 npm run e2e:playwright"
   },
   "csp": {
    "v": "no",
    "ev": "no CSP configured in template/app"
   },
   "dep_updates": {
    "v": "no",
    "ev": "no dependabot/renovate config"
   },
   "notes": "Lemon Squeezy subscription events are deliberately not deduped (no event id; handlers set absolute values, lemonSqueezy/webhook.ts:35-37). Wasp's generated Express server may apply helmet() by default (framework, not in repo), which would set a CSP on API-server responses only; the client SPA's hosting headers are not configured in the repo, so CSP = no for the app."
  },
  "XCO-Agency/Laravel-SAAS-Starter": {
   "commit": "e35e0795a9219c18f1e7c80f24a888e4b35773a4",
   "billing": {
    "v": "yes",
    "provider": "Stripe (Laravel Cashier)",
    "ev": "app/Http/Controllers/BillingController.php:194-198 newSubscription()->checkout(); routes/web.php:333 webhook; composer.json:32 laravel/cashier"
   },
   "webhook_signature": {
    "v": "yes",
    "ev": "library: StripeWebhookController extends Cashier WebhookController (app/Http/Controllers/StripeWebhookController.php:10), which applies VerifyWebhookSignature when cashier.webhook.secret is set; config/cashier.php:47-49 reads STRIPE_WEBHOOK_SECRET"
   },
   "webhook_duplicates": {
    "v": "partial",
    "ev": "Cashier handlers sync subscription state by stripe_id; unique stripe_id in database/migrations/2025_12_02_180003_create_subscriptions_table.php:18; custom overrides only log (StripeWebhookController.php:17-100); no event-id store",
    "on_duplicate": "Cashier finds the existing subscription by stripe_id and re-applies the same state; the app's extra logic only writes a log line again."
   },
   "rate_limit": {
    "v": "yes",
    "ev": "app/Providers/FortifyServiceProvider.php:90-93 login 5/min; config/fortify.php:117-120; routes/settings.php:30 throttle:6,1; routes/web.php:174,309,325 throttle:invitations/api; AppServiceProvider.php:104-110"
   },
   "tests": {
    "count": 247,
    "kind": "feature + unit (Pest/PHPUnit)",
    "ci": "yes",
    "ev": "tests/Feature (245) + tests/Unit (2); .github/workflows/tests.yml:67 php artisan test"
   },
   "csp": {
    "v": "no",
    "ev": "no Content-Security-Policy header set in app/ or bootstrap/ (only X-Request-Id)"
   },
   "dep_updates": {
    "v": "yes",
    "ev": ".github/dependabot.yml (composer, npm, github-actions weekly)"
   },
   "notes": "Signature verification is skipped silently by Cashier if STRIPE_WEBHOOK_SECRET is unset (Cashier default behavior). Test count is file count; many are Feature tests per controller. Repo is young/low-profile relative to others in the batch."
  }
 },
 "not_scored": {
  "shadcn-ui/taxonomy": "Archived; its README says it does not reflect current best practices and is not for production.",
  "async-labs/saas": "Companion code for a book, not a maintained product.",
  "makerkit/nextjs-saas-starter-kit-lite": "Billing ships in the paid MakerKit kit, not in this repository.",
  "bullet-train-co/bullet_train": "Billing ships in a paid add-on, not in this repository.",
  "ixartz/SaaS-Boilerplate": "Billing ships in paid tiers, not in this repository."
 }
}