NEW v1.0.0 released — Initial release.
Core guarantees
✓
No user created without confirmed payment
Account creation only happens after the PaymentIntent status is verified server-side with Stripe. Client-reported success is never trusted.
✓
Duplicate webhooks are safe
Every event is stored with its Stripe event ID. Replays and retries are idempotent — processing the same event twice produces no duplicate accounts or charges.
✓
System recovers from partial failure
If the browser closes after payment but before credentials reach the server, the webhook sends a registration email with a secure token link valid for 72 hours. The user clicks the link, sets their password, and the account is created. SMTP must be configured for this path to work in production.
✓
Async payment methods handled correctly
Klarna, ACH, and BACS payments that confirm hours or days later are handled via the same webhook email-link path. No account is created prematurely during a processing state — account creation only happens on payment_intent.succeeded.
✓
Plaintext passwords never persist
bcrypt hashing happens on the server before any write to the database. Plaintext credentials are never stored, logged, or passed to third-party services.
✓
Implementation note
These guarantees rely on relational constraints and transactional integrity. PostgreSQL is the reference implementation used throughout this documentation. Alternative databases may require small adjustments.
Supported stacks
StackLanguageNotes
ASP.NETC# / .NET 9Dapper + Npgsql, BCrypt.Net, cookie sessions, PostgreSQL
Next.jsJavaScriptApp Router, API routes, iron-session, raw pg, PostgreSQL
FastAPIPython 3.11+Async SQLAlchemy 2.0, asyncpg, Starlette sessions, PostgreSQL
Frontend included
The HTML / JS / CSS frontend is included with every purchase and works with all backend stacks without modification. You do not need a framework — it is raw HTML and vanilla JavaScript.
1 — Prerequisites

Before cloning, make sure you have the following installed:

  • .NET 9 SDK — dotnet --version should return 9.x
  • Stripe CLI — for local webhook forwarding
  • A Stripe account with test mode enabled
  • A running PostgreSQL instance (local or cloud)
2 — Download your repo

Your repo is provisioned via the dashboard. After generating a project, your repo can be downloaded.

3 — Configure environment

The app reads Stripe keys exclusively from environment variables — not from appsettings.json. Set them in your shell before running:

terminal (bash / zsh)
export STRIPE_SECRET_KEY=sk_test_...
export STRIPE_PUBLISHABLE_KEY=pk_test_...
export STRIPE_WEBHOOK_SECRET=whsec_...          # add after step 4
export DATABASE_CONNECTION_STRING="Host=localhost;Port=5432;Database=regkit;Username=postgres;Password=secret;"

Optionally update appsettings.json to change the price amount, currency, Price ID, or SMTP settings:

appsettings.json (optional overrides)
{
                                "Stripe": {
                                "PriceInSmallestUnit": 15000,   // £150.00 — edit as needed
                                "Currency": "gbp",
                                "PriceId": "price_YOUR_PRICE_ID"
  },
                                "App": { "BaseUrl": "http://localhost:5000" }
}
4 — Start webhook forwarding

Open a second terminal and run the Stripe CLI. Copy the whsec_... value it prints and set it as STRIPE_WEBHOOK_SECRET.

terminal — separate window
stripe listen --forward-to http://localhost:5000/webhook
5 — Run and test
terminal
dotnet run

Navigate to http://localhost:5000. The schema is created automatically on first startup — no migration command needed. Use Stripe's test card to trigger a successful payment:

  • Card number: 4242 4242 4242 4242
  • Expiry: any future date
  • CVC: any 3 digits

After submitting, you should land in the dashboard. Check your terminal — you will see the webhook event received and the user creation logged.

You're set up
End-to-end flow is working. Payment captured → webhook received → account created. Next: read the Architecture overview to understand how the pieces fit together before modifying anything.
1 — Prerequisites
  • Node.js 18+ — node --version should return 18.x or higher
  • pnpm (recommended) or npm
  • Stripe CLI — for local webhook forwarding
  • A Stripe account with test mode enabled
2 — Download and install

Your repo is provisioned via the dashboard. After generating a project, your repo can be downloaded.

3 — Configure environment
terminal
cp .env.example .env.local
.env.local
STRIPE_SECRET_KEY=sk_test_...           # required
STRIPE_PUBLISHABLE_KEY=pk_test_...     # required
STRIPE_WEBHOOK_SECRET=whsec_...        # add after step 4
DATABASE_URL=postgresql://postgres:password@localhost:5432/regkit
SESSION_SECRET=# generate: node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
APP_BASE_URL=http://localhost:3000     # used in registration email links
4 — Start webhook forwarding

No migration command is needed — the schema is applied automatically on first request. Start webhook forwarding in a separate terminal. The schema initialises on first API call.

terminal — separate window
stripe listen --forward-to localhost:3000/api/webhook

Copy the whsec_... value printed by the Stripe CLI and set it as STRIPE_WEBHOOK_SECRET in .env.local.

5 — Run and test
terminal
npm run dev

Navigate to http://localhost:3000 and submit the signup form using test card 4242 4242 4242 4242 with any future expiry and any CVC.

You're set up
Payment captured → webhook received → account created. Read the Architecture overview before making modifications.
1 — Prerequisites
  • Python 3.11+ — python --version
  • uv (recommended) or pip + venv
  • Stripe CLI — for local webhook forwarding
  • A Stripe account with test mode enabled
2 — Download and install

Your repo is provisioned via the dashboard. After generating a project, your repo can be downloaded.

3 — Configure environment
terminal
cp .env.example .env
.env
STRIPE_SECRET_KEY=sk_test_...        # required
STRIPE_PUBLISHABLE_KEY=pk_test_...   # required
STRIPE_WEBHOOK_SECRET=whsec_...      # add after step 4
DATABASE_URL=postgresql+asyncpg://postgres:password@localhost:5432/regkit
SECRET_KEY=# generate: python -c "import secrets; print(secrets.token_hex(32))"
APP_BASE_URL=http://localhost:8000   # used in registration email links
4 — Start webhook forwarding

Apply the database migration first (production always uses Alembic — never create_all), then start webhook forwarding in a second terminal:

terminal
alembic upgrade head
terminal — separate window
stripe listen --forward-to localhost:8000/webhook

Copy the whsec_... value into your .env as STRIPE_WEBHOOK_SECRET.

5 — Run and test
terminal
uvicorn app.main:app --reload

Navigate to http://localhost:8000 and test with card 4242 4242 4242 4242. API docs are available at http://localhost:8000/docs.

You're set up
Payment captured → webhook received → account created. Health check available at http://localhost:8000/health. API docs at http://localhost:8000/docs. Read the Architecture overview before making modifications.
The problem with the standard flow

Most SaaS signup flows work like this: load Stripe Checkout and take payment → confirm payment → send a magic link → redirect back to app, or create account → redirect to payment → confirm payment → redirect back to app. This creates unnecessary drop-off. The user is asked to commit (create an account) before they have completed the purchase. Every step is a potential exit point.

The RegKit pattern flips this: the user fills in email, password, and card details in a single form. Payment is collected first. The account is created only after payment is confirmed. There is no multi-step redirect. There is no half-created account in your database.

How Stripe Elements fits in

RegKit uses Stripe Elements, not Stripe Checkout. This is a deliberate choice. With Stripe Elements, the card fields are rendered in a Stripe-hosted iframe embedded directly in your form. The user never leaves your page. Your backend is in the loop throughout — you create the PaymentIntent, you verify it, you create the account.

Stripe Checkout (the hosted page approach) hands control to Stripe. That model requires JWT encoding of credentials in metadata and webhook-only account creation. RegKit avoids this complexity entirely as it's not secure.

Dual confirmation: inline + webhook

There are two paths through the system. They are not alternatives — both are always in play. The inline path handles the happy case. The webhook handles everything that falls through.

INLINE PATH — happy case
Browser stays open. Payment confirms. JavaScript posts email, password, and PaymentIntent ID to your backend. Backend verifies with Stripe, hashes the password, and creates the account immediately. User lands in the dashboard.
WEBHOOK PATH — safety net
Browser closes, network drops, or async payment method used. Stripe sends payment_intent.succeeded. The webhook resolves the customer's email from Stripe, creates a pending_registration row with a secure token, and emails the user a link to set their password and complete registration.
Why the webhook path is different
In the inline path, credentials (email + password) arrive together with the PaymentIntent ID — the account can be created immediately. In the webhook path, Stripe fires the event without any credentials — only the PaymentIntent and customer are available. The system cannot create an account without a password. Instead it emails the user a time-limited link (72 hours) to set their password and complete registration. SMTP must be configured in production for this path to work.
Why server-side verification is non-negotiable
Never trust client-reported payment success
A malicious user can intercept the request between the browser and your server and post a fake PaymentIntent ID. If your backend creates an account based on anything the client tells you, that attack vector is open. RegKit verifies the PaymentIntent directly with Stripe on the server on every request. This step cannot be removed.
Key design decisions
DecisionRationale
Stripe Elements, not CheckoutKeeps everything on one page, backend in the loop, no JWT credential encoding needed
Server-side PaymentIntent verificationCloses the fake payment vector — client is never trusted
bcrypt on the serverPlaintext credentials never touch the database or third-party services
Idempotency keys on all Stripe callsSafe retries without duplicate charges
Webhook signature verificationAll incoming webhook events validated against Stripe's signature before processing
Event storage before processingWebhook events written to the database before any business logic runs — enables replay and audit
Happy path — inline confirmation
browser
User submits the form
Email, password, and card details entered. JavaScript intercepts the submit event — nothing is posted to your server yet.
browser → stripe
stripe.confirmPayment() called
Card details travel directly from the browser to Stripe via the Elements iframe. They never pass through your server. Stripe returns a PaymentIntent with status succeeded.
browser → your server
Email, password, and PaymentIntent ID posted to backend
The PaymentIntent ID is the only thing from Stripe that touches your server at this point. Email and password are included for account creation — they have not been stored anywhere yet.
your server → stripe api
PaymentIntent verified server-side
Your backend retrieves the PaymentIntent directly from Stripe and checks that its status is succeeded. If it is not, the request is rejected and no account is created.
your server
Password hashed with bcrypt
The plaintext password is hashed on the server. It is never written to the database, never logged, never passed to Stripe or any other service.
your server → database
Account created
User record written with hashed password, email, and Stripe customer/subscription IDs. The idempotency key tied to the PaymentIntent is recorded — the webhook path will see this and skip creation.
your server → browser
Session token returned — user lands in dashboard
A session or JWT is issued. The browser is redirected to the authenticated area. End-to-end time: typically under 3 seconds.
Failure paths
✕
Payment fails or card declined
Stripe returns an error from confirmPayment(). JavaScript surfaces the error in the form. No data is posted to your server. No account is created. No partial record exists. The user can correct their card and retry.
!
Browser closes after payment, before account creation
Payment succeeded in Stripe, but the inline path never posted credentials to the server. Stripe sends payment_intent.succeeded to your webhook. The webhook resolves the customer's email from Stripe, creates a pending_registration row, generates a secure token, and sends a registration email with a link valid for 72 hours. The user clicks the link, sets their password, and the account is created at that point.
!
Webhook fires but inline path already completed
The webhook handler checks for an existing user with the same stripe_payment_intent_id before doing anything. If the inline path already created the account, the webhook exits cleanly with no side effects. The webhook_events table (all three stacks) prevents any duplicate processing via atomic INSERT ... ON CONFLICT DO NOTHING on the Stripe event ID.
!
Async payment method pending (Klarna, ACH, BACS)
The inline path receives a processing status and shows the user a pending confirmation screen. No account is created yet. When Stripe confirms the payment (hours or days later), payment_intent.succeeded fires and the webhook follows the same email-link path as the browser-closed case.
✕
Duplicate submission
If the user submits twice (double-click, network retry), the idempotency key on the PaymentIntent prevents a duplicate charge. If a second account creation is attempted with the same email or the same PaymentIntent ID, the unique constraints on the users table prevent a duplicate record.
Signature verification

Every incoming webhook request is verified against Stripe's signature before any processing begins. Stripe signs each webhook with the STRIPE_WEBHOOK_SECRET from your dashboard. Your endpoint reconstructs the signature from the raw request body and timestamp and compares it.

If verification fails, the request is rejected with a 400. No business logic runs. This prevents malicious or malformed events from affecting your database.

Use the raw request body
Stripe's signature is computed over the raw bytes of the request. If your framework parses the body before you can access it (e.g. JSON middleware), the signature will always fail. All stacks in RegKit configure the webhook route to receive the raw body before any middleware processes it.
Event storage and idempotency

All three stacks use a webhook_events table as an atomic idempotency log. Before any business logic runs, the handler attempts to insert the Stripe event ID. The insert uses ON CONFLICT DO NOTHING, so if the same event ID was already processed, the insert is a no-op and the handler returns 200 immediately without running any logic again.

What is and isn't stored
The webhook_events table stores the Stripe event ID, event type, and timestamp only — not the full payload. If you want to store full payloads for auditing or replay, add a payload column and populate it in the handler. The unique constraint on event ID will still enforce idempotency regardless of whether you store the payload.
Idempotency

Stripe retries webhook delivery if it does not receive a 2xx response within a timeout window. This means your endpoint may receive the same event multiple times. The RegKit webhook handler is idempotent by design.

1
Event received and signature verified
2
Check event ID in webhook_events table
If the event ID already exists, return 200 immediately. No further processing. Stripe will stop retrying.
3
Write event to webhook_events table
The write uses the Stripe event ID as a unique key. If a concurrent request for the same event tries to insert, the unique constraint prevents it.
4
Run business logic
Account creation, subscription activation. Checks for existing user tied to the PaymentIntent before creating — handles the case where the inline path completed first.
5
Return 200
Stripe marks the event as delivered and stops retrying.
Handled events
EventHandler action
payment_intent.succeededCheck for existing user — if none, resolve customer email from Stripe, create pending_registration row, send registration email with token link
payment_intent.payment_failedLog failure details; optionally send failure notification email to the customer; clean up any pending_registration row tied to this PaymentIntent
payment_intent.processingLog that async payment is in progress (Klarna, ACH, BACS). Account creation deferred until succeeded fires.
payment_intent.canceledClean up any pending_registration row tied to this PaymentIntent
charge.dispute.createdLogged at warning level with dispute ID, amount, and reason for manual follow-up
(all others)Logged and 200 returned — Stripe stops retrying
What the system guarantees
✓
Duplicate events produce no duplicate side effects
The event ID unique key prevents the same event from being processed twice even under concurrent delivery.
✓
Out-of-order events are handled safely
Each event handler checks current state before acting. A subscription.deleted event arriving before invoice.paid will not corrupt the subscription record.
✓
Every event is replayable
You can replay any event from the Stripe dashboard or using the Stripe CLI without risk of double-processing — idempotency handles it.
PostgreSQL required
All three stacks use PostgreSQL exclusively. Update DATABASE_URL (FastAPI / Next.js) or DATABASE_CONNECTION_STRING (.NET) in your environment to point at your Postgres instance. For FastAPI, run alembic upgrade head before first startup. For .NET and Next.js, the schema is applied automatically on first startup via idempotent DDL.
◫ users
id
serial / int
Primary key, auto-increment
email
text
Unique index on lower(email) — prevents duplicate accounts, case-insensitive
password_hash
text
bcrypt hash (cost 12), never plaintext
stripe_customer_id
text
Stripe Customer ID, created at account creation time
stripe_payment_intent_id
text
Unique — the idempotency key that prevents duplicate account creation
account_status / status
text
active | pending — used to block login on accounts awaiting payment confirmation
created_at
timestamptz
Set on insert, timezone-aware
◫ pending_registrations
id
serial / int
Primary key
stripe_payment_intent_id
text
Unique — prevents duplicate pending rows on webhook retry
stripe_customer_id
text
Resolved from Stripe at webhook time
email
text
Resolved from Stripe Customer at webhook time
token
text
Unique secure random token — sent in the registration email link
expires_at
timestamptz
72 hours from creation — link expires after this
created_at
timestamptz
Set on insert
◫ webhook_events All stacks
stripe_event_id
text
Primary key — Stripe event ID. The unique constraint is the entire idempotency mechanism.
event_type
text
e.g. payment_intent.succeeded
processed_at
timestamptz
Set on first successful insert
Do not remove the unique constraints
The unique constraints on users.email, users.stripe_payment_intent_id, pending_registrations.stripe_payment_intent_id, pending_registrations.token, and webhook_events.stripe_event_id are what enforce the system's guarantees at the database level. Removing them breaks idempotency and duplicate prevention.
VariableDescriptionDefault
STRIPE_SECRET_KEYrequiredStripe secret key — sk_test_ or sk_live_. Validated against the Stripe API at startup.—
STRIPE_PUBLISHABLE_KEYrequiredStripe publishable key — pk_test_ or pk_live_. Sent to the browser to initialise Stripe Elements.—
STRIPE_WEBHOOK_SECRETrequiredWebhook signing secret from stripe listen (dev) or Stripe Dashboard (prod).—
DATABASE_URLrequiredPostgreSQL asyncpg DSN: postgresql+asyncpg://user:pass@host:5432/dbnamelocalhost/regkit
SECRET_KEYrequiredSigns Starlette session cookies. Generate with python -c "import secrets; print(secrets.token_hex(32))"auto-generated (insecure)
STRIPE_PRICE_AMOUNToptionalPrice in smallest currency unit (pence/cents). 15000 = £150.00 or $150.00.15000
STRIPE_CURRENCYoptionalISO 4217 currency code (lowercase): gbp, usd, eur.gbp
APP_BASE_URLoptionalPublic URL of your app. Used in registration email links (webhook path).http://localhost:8000
SMTP_HOSToptionalSMTP hostname. If unset, emails are logged to console — useful for development.—
SMTP_PORToptionalSMTP port.587
SMTP_USERoptionalSMTP username.—
SMTP_PASSWORDoptionalSMTP password.—
SMTP_FROMoptionalSender address for transactional emails.noreply@example.com
SMTP_USE_TLSoptionalEnable STARTTLS. Set false for local mailhog / mailpit.true
SESSION_HTTPS_ONLYoptionalSet true in production so the session cookie is HTTPS-only. Leave false for local HTTP development.false
LOG_LEVELoptionalDEBUG / INFO / WARNING / ERRORINFO
VariableDescriptionDefault
STRIPE_SECRET_KEYrequiredStripe secret key — sk_test_ or sk_live_.—
STRIPE_PUBLISHABLE_KEYrequiredStripe publishable key — pk_test_ or pk_live_. Used by the checkout page client-side.—
STRIPE_WEBHOOK_SECRETrequiredWebhook signing secret. Config validation checks for placeholder values at startup.—
DATABASE_URLrequiredPostgreSQL connection string: postgresql://user:pass@host:5432/dbname. SSL is enabled automatically in production.—
SESSION_SECRETrequiredSigns iron-session cookies. Generate with node -e "console.log(require('crypto').randomBytes(64).toString('hex'))". Must be 32+ chars.—
APP_BASE_URLoptionalBase URL for registration email links (webhook path). Must be the public URL in production.http://localhost:3000
SMTP_HOSToptionalSMTP hostname for transactional emails. Emails are logged to console if unset.—
SMTP_PORToptionalSMTP port.587
SMTP_USERoptionalSMTP username.—
SMTP_PASSoptionalSMTP password.—
SMTP_FROMoptionalSender address for transactional emails.noreply@example.com
Variable / KeyDescriptionDefault
STRIPE_SECRET_KEYrequiredStripe secret key (environment variable). Startup exits with a clear error message if missing.—
STRIPE_PUBLISHABLE_KEYrequiredStripe publishable key (environment variable).—
STRIPE_WEBHOOK_SECRETrequiredWebhook signing secret (environment variable).—
DATABASE_CONNECTION_STRINGrequiredPostgreSQL connection string via environment variable, or Database:ConnectionString in appsettings.json as fallback.—
Stripe:PriceInSmallestUnitoptionalPrice in pence/cents. Set in appsettings.json.15000
Stripe:CurrencyoptionalISO 4217 currency code (lowercase). Set in appsettings.json.gbp
Stripe:PriceIdoptionalStripe Price ID for subscription creation. Set in appsettings.json.price_YOUR_PRICE_ID
App:BaseUrloptionalPublic URL — used in registration email links. Set in appsettings.json.http://localhost:5000
Email:SmtpHostoptionalSMTP hostname. Set in appsettings.json.localhost
Email:SmtpPortoptionalSMTP port.25
Email:SmtpUser / SmtpPasswordoptionalSMTP credentials. Leave blank for unauthenticated local relay.—
Email:FromAddressoptionalSender address for transactional emails.noreply@example.com
Changing the price

Pricing is set by two values: a Stripe Price ID (used for subscription creation) and a raw amount in the smallest currency unit (pence or cents, used when creating the PaymentIntent). Both must be kept in sync.

  1. Create your product and price in the Stripe dashboard
  2. Update STRIPE_PRICE_AMOUNT (FastAPI / Next.js) or Stripe:PriceInSmallestUnit (.NET) to the new amount
  3. Update Stripe:PriceId (.NET) or the price ID reference in your checkout config if you are also creating subscriptions

Amount and currency are always set server-side — the frontend never dictates pricing. The client cannot manipulate the charge amount.

Auth and session configuration

Session duration

FastAPI uses Starlette SessionMiddleware with a 7-day cookie (max_age=86400 * 7). Set https_only=True in production.
Next.js uses iron-session with a 7-day cookie (maxAge: 7 * 24 * 60 * 60). The secure flag is set automatically in production.
ASP.NET uses distributed memory sessions with a 60-minute idle timeout. The session cookie is HttpOnly, SameSite=Strict, and Secure=Always in production.

Protected routes

In Next.js, route protection is handled in src/lib/middleware/auth.js. In FastAPI, the require_login dependency in app/dependencies.py is applied per route. In .NET, the session check is inline in each controller action that requires authentication.

SMTP — required for the webhook path

When a user's browser closes after payment but before account creation, the webhook creates a pending_registration row and sends a registration link by email. If SMTP is not configured, this email is logged to the console. In production, configure SMTP to ensure these users can complete registration. Without it, users who hit the webhook path have no way to set their password.

✓ Safe to modify
  • Frontend HTML, CSS, and non-payment JavaScript
  • Post-signup redirect destination
  • Email templates and copy
  • Dashboard and authenticated routes
  • Database schema — add columns, add tables
  • Stripe Price IDs and plan names
  • Session expiry duration
  • Password validation rules and minimum length
  • SMTP configuration
✕ Do not modify
  • Webhook signature verification logic
  • Server-side PaymentIntent verification step
  • Idempotency key generation or storage
  • Database unique constraints (see Data model)
  • bcrypt hashing — do not reduce work factor below 10
  • The order of operations in the signup handler
  • Raw body parsing for the webhook route
Adding a free trial

Trials are managed at the Stripe subscription level. To add a trial period:

  1. Set a trial_period_days value on the Stripe Price in your dashboard, or pass it when creating the Subscription in your backend
  2. Update your subscription creation code to include trial_end or trial_period_days in the Stripe API call
  3. Handle the customer.subscription.trial_will_end webhook event to send a trial-ending reminder email

No changes to the payment or account creation flow are required. The trial is transparent to RegKit — the PaymentIntent still succeeds, the account is still created, the subscription status will be trialing until the trial ends.

Changing password validation rules

Password validation runs in two places: the frontend (immediate feedback as the user types) and the server (the authoritative check — never rely on frontend-only validation). You need to update both to keep them in sync. The strength meter is visual only and does not gate the submit — only the hard minimum check blocks submission.

All three stacks use the same rule by default: minimum 8 characters, no other requirements enforced.

Password validation runs in two places: the frontend (immediate feedback as the user types) and the server (the authoritative check — never rely on frontend-only validation). You need to update both to keep them in sync. The strength meter is visual only and does not gate the submit — only the hard minimum check blocks submission.

All three stacks use the same rule by default: minimum 8 characters, no other requirements enforced.

Step 1 — Server validation (authoritative). Open src/lib/utils/index.js and edit the validatePassword function:

src/lib/utils/index.js
/**
 * Validates password strength.
 * Returns an error message string, or null if valid.
 */
function validatePassword(password) {
                                // ── Minimum length ──────────────────────────────────────
                                if (!password || password.length < 8) {          // ← change 8 to your minimum
                                return 'Password must be at least 8 characters.';
  }

                                // ── Optional: require at least one uppercase letter ─────
                                // if (!/[A-Z]/.test(password)) {
                                //   return 'Password must contain at least one uppercase letter.';
                                // }

                                // ── Optional: require at least one number ───────────────
                                // if (!/[0-9]/.test(password)) {
                                //   return 'Password must contain at least one number.';
                                // }

                                // ── Optional: require at least one special character ────
                                // if (!/[^A-Za-z0-9]/.test(password)) {
                                //   return 'Password must contain at least one special character.';
                                // }

                                return null; // valid
}

Step 2 — Frontend hard check. Open src/app/checkout/CheckoutForm.jsx and match the same minimum:

src/app/checkout/CheckoutForm.jsx
// ~line 153 — match this to your server minimum
if (!password || password.length < 8) // ← same value as server
  errs.password = 'Password must be at least 8 characters.';

Step 3 — Strength meter (visual only, does not block submit). Same file, near the top:

src/app/checkout/CheckoutForm.jsx
// The meter scores 0–4 based on which criteria are met.
// Adjust thresholds to match whatever rules you've enforced above.
let score = 0;
if (v.length >= 8)          score++; // ← match your minimum
if (/[A-Z]/.test(v))        score++; // uppercase present
if (/[0-9]/.test(v))        score++; // digit present
if (/[^A-Za-z0-9]/.test(v)) score++; // special char present
Webhook path: register complete
If you add rules beyond minimum length, apply the same check in src/app/api/register/complete/route.js — this is the route used when a user clicks their registration email link after a dropped connection. It calls validatePassword() from the same utils file, so if you updated that function you're already covered.

Step 1 — Server validation (authoritative). Open app/schemas/__init__.py. Password rules are enforced via Pydantic Field constraints and field_validator. Edit the two schemas that accept a password:

app/schemas/__init__.py
from pydantic import BaseModel, EmailStr, Field, field_validator


class ConfirmCheckoutRequest(BaseModel):
    email: EmailStr
    password: str = Field(..., min_length=8)  # ← change minimum here
    payment_intent_id: str = Field(..., pattern=r"^pi_")

                                # ── Optional: add custom rules as a field_validator ──────
                                # @field_validator("password")
                                # @classmethod
                                # def password_strength(cls, v: str) -> str:
                                #     if not any(c.isupper() for c in v):
                                #         raise ValueError("Password must contain at least one uppercase letter.")
                                #     if not any(c.isdigit() for c in v):
                                #         raise ValueError("Password must contain at least one number.")
                                #     if not any(not c.isalnum() for c in v):
                                #         raise ValueError("Password must contain at least one special character.")
                                #     return v


class CompleteRegistrationRequest(BaseModel):
    token: str = Field(..., min_length=1)
    password: str = Field(..., min_length=8)  # ← match the value above
    password_confirm: str = Field(..., min_length=1)

                                # Add the same field_validator here if you added one above

Step 2 — Frontend hard check. Open app/static/js/checkout.js and match the same minimum:

app/static/js/checkout.js
// ~line 176 — match this to your server minimum
if (!password || password.length < 8) { // ← same value as schema
                                // show error
}

Step 3 — Strength meter (visual only). Same file:

app/static/js/checkout.js
var v = el.value, score = 0;
if (v.length >= 8)          score++; // ← match your minimum
if (/[A-Z]/.test(v))        score++;
if (/[0-9]/.test(v))        score++;
if (/[^A-Za-z0-9]/.test(v)) score++;
Pydantic validation errors
Pydantic returns a 422 Unprocessable Entity with field-level detail when validation fails. The frontend already handles this — the error message from your ValueError or Field description will surface directly to the user.

Step 1 — Server validation (authoritative). Password validation lives in two controllers. Open Controllers/CheckoutController.cs:

Controllers/CheckoutController.cs
// ── Step 1: Validate input ───────────────────────────────
if (string.IsNullOrWhiteSpace(model.Password) || model.Password.Length < 8)  // ← change 8
                                return BadRequest(new { error = "Password must be at least 8 characters." });

// ── Optional: add extra rules directly below ─────────────
// if (!model.Password.Any(char.IsUpper))
//     return BadRequest(new { error = "Password must contain at least one uppercase letter." });
// if (!model.Password.Any(char.IsDigit))
//     return BadRequest(new { error = "Password must contain at least one number." });
// if (model.Password.All(char.IsLetterOrDigit))
//     return BadRequest(new { error = "Password must contain at least one special character." });

Apply the same checks in Controllers/RegisterController.cs (the webhook fallback path — used when a user clicks their registration email link):

Controllers/RegisterController.cs
// POST /register/complete — same rules as CheckoutController
if (string.IsNullOrWhiteSpace(model.Password) || model.Password.Length < 8)  // ← match above
                                return BadRequest(new { error = "Password must be at least 8 characters." });

// Add the same optional rules here

Step 2 — Frontend hard check. Open wwwroot/js/checkout.js:

wwwroot/js/checkout.js
// ~line 143 — match this to your server minimum
if (!password || password.length < 8) { // ← same value as controller
  showFieldError('password-error', 'Password must be at least 8 characters.');
                                return;
}

Step 3 — Strength meter (visual only). Same file:

wwwroot/js/checkout.js
let score = 0;
if (v.length >= 8)          score++; // ← match your minimum
if (/[A-Z]/.test(v))        score++;
if (/[0-9]/.test(v))        score++;
if (/[^A-Za-z0-9]/.test(v)) score++;
Two controllers, keep them in sync
The CheckoutController handles the normal (inline) signup path. The RegisterController handles the webhook fallback path. Both must enforce the same rules — a customer who hits the fallback path should face identical requirements.
Alternative database supportadvanced

PostgreSQL is the reference implementation used throughout this documentation. The system guarantees (idempotency, uniqueness, and safe retries) rely on relational constraints and transactional integrity.

Alternative databases can be used, but require small adjustments to drivers, schema types, and connection configuration. The examples below show the minimal changes needed — the core flow and architecture remain the same.

1 — Install driver
terminal
dotnet add package Microsoft.Data.SqlClient
2 — Update connection string

Replace the PostgreSQL connection string with a SQL Server connection string:

environment
DATABASE_CONNECTION_STRING="Server=localhost;Database=regkit;User Id=sa;Password=Your_password123;"
3 — Replace database provider

The default implementation uses Npgsql. Replace it with SqlConnection.

C#
using Microsoft.Data.SqlClient;

using var connection = new SqlConnection(connectionString);
await connection.OpenAsync();

Dapper usage remains unchanged — only the connection type differs.

4 — Schema adjustments

Apply the following type changes when creating tables:

  • SERIAL → INT IDENTITY(1,1)
  • BOOLEAN → BIT
  • TIMESTAMP → DATETIME2

Unique constraints on email and stripe_payment_intent_id must be preserved.

5 — Important differences
  • Parameter syntax: SQL Server uses @param instead of @param (compatible with Dapper).
  • Case sensitivity: Depends on collation — ensure email comparisons behave as expected.
  • Migrations: Schema auto-creation logic may need minor adjustments for SQL Server syntax.
Production note
SQL Server is fully suitable for production use. Ensure indexes and constraints are applied correctly — idempotency guarantees depend on them.
1 — Install driver
terminal
pnpm add better-sqlite3
2 — Update environment

Replace the PostgreSQL connection string with a local SQLite file:

.env.local
DATABASE_URL=file:./regkit.db
3 — Replace database client

The default implementation uses pg. Replace it with a SQLite client.

lib/db.js (example)
import Database from 'better-sqlite3';

const db = new Database('./regkit.db');

export default db;

Queries remain the same in structure — replace parameter syntax where needed.

4 — Schema adjustments

SQLite uses different type mappings. Apply the following changes:

  • SERIAL → INTEGER PRIMARY KEY AUTOINCREMENT
  • BOOLEAN → INTEGER (0 / 1)
  • TIMESTAMP → TEXT (ISO8601)

Unique constraints and indexes should be preserved exactly — they are required for idempotency guarantees.

5 — Important differences
  • Concurrency: SQLite allows a single writer. High-concurrency production workloads should use PostgreSQL.
  • Transactions: Fully supported, but long-running writes will block other operations.
  • Deployment: The database is a local file — ensure it persists across deploys (e.g. mounted volume).
Production recommendation
SQLite is suitable for local development, prototypes, and low-traffic deployments. For production SaaS workloads with concurrent users and webhook traffic, PostgreSQL is strongly recommended.
1 — Install driver
terminal
uv pip install aiosqlite
2 — Update environment

Replace the PostgreSQL connection string with a SQLite file:

.env
DATABASE_URL=sqlite+aiosqlite:///./regkit.db
3 — Update engine configuration

Adjust the SQLAlchemy engine to support SQLite:

Python
from sqlalchemy.ext.asyncio import create_async_engine

engine = create_async_engine(
    DATABASE_URL,
    connect_args={"check_same_thread": False}
)

No changes are required to your ORM models.

4 — Schema adjustments

SQLite uses simplified type mappings:

  • INTEGER PRIMARY KEY replaces auto-increment IDs
  • BOOLEAN → INTEGER (0 / 1)
  • TIMESTAMP → TEXT

Ensure all unique constraints remain in place — they are required for webhook idempotency.

5 — Important differences
  • Concurrency: Single-writer model — concurrent webhook processing may queue.
  • Transactions: Supported, but long writes block other operations.
  • Persistence: Database is a local file — ensure it is not ephemeral in production.
Production recommendation
SQLite is suitable for development and low-traffic deployments. For production systems handling concurrent payments and webhooks, PostgreSQL is strongly recommended.
Adding coupons and promotional codes

Create coupons in the Stripe dashboard. To apply a coupon at checkout, pass the coupon ID or promotion code to the PaymentIntent or Subscription creation in your backend. Add a coupon field to the frontend form and pass the value to your backend alongside the PaymentIntent ID.

Do not apply discounts client-side. Always apply and validate coupons on the server.

Before deploying to production
Switch all Stripe keys from sk_test_ / pk_test_ to sk_live_ / pk_live_. Register your production webhook endpoint in the Stripe dashboard and update STRIPE_WEBHOOK_SECRET with the live signing secret. Test-mode and live-mode webhook secrets are different.
Production checklist
1
Switch to live Stripe keys
Update STRIPE_SECRET_KEY and STRIPE_PUBLISHABLE_KEY in your production environment variables.
2
Register production webhook endpoint
In the Stripe dashboard: Developers → Webhooks → Add endpoint. Set the URL to your production domain + the webhook path. For ASP.NET: https://yourdomain.com/webhook. For Next.js / FastAPI: https://yourdomain.com/api/webhook or https://yourdomain.com/webhook respectively.. Select the events listed in the Webhook system section.
3
Copy the live webhook signing secret
After adding the endpoint, Stripe shows the signing secret. Set this as STRIPE_WEBHOOK_SECRET in your production environment.
4
Run database migrations
FastAPI: Run alembic upgrade head before starting. .NET / Next.js: Schema is applied automatically via idempotent DDL on startup — no migration command needed.
5
Set SECRET_KEY to a random value
Generate a cryptographically random string of at least 32 characters. Do not reuse your development key.
6
Test with a live card
Make a real test purchase with a live Stripe key before announcing. Verify the webhook fires, the account is created, and the session is issued correctly. Stripe's dashboard will show the payment in live mode.
Stack-specific deployment notes

Publish with dotnet publish -c Release and deploy the output directory to your host. Works on any Linux server or Azure App Service. Set the four required environment variables (STRIPE_SECRET_KEY, STRIPE_PUBLISHABLE_KEY, STRIPE_WEBHOOK_SECRET, DATABASE_CONNECTION_STRING) via your host's environment configuration — never in appsettings.json.

The schema is created automatically on startup via idempotent IF NOT EXISTS DDL — no migration command is needed before deploy. The app exits immediately with a clear console error if any required environment variable is missing.

Run npm run build && npm start on any Node.js 18+ host. The schema is applied automatically on first request via the ensureDb() initialiser — no migration command needed.

Deploy with make run (development) or docker compose up (production). Run alembic upgrade head against your production database before starting — the FastAPI stack uses Alembic for all schema changes, not create_all. The app exits with sys.exit(1) on startup if any required environment variable is missing or if the Stripe API key fails validation.

SESSION_HTTPS_ONLY=true must be set in production so the session cookie is HTTPS-only. A health check endpoint is available at /health — configure your load balancer or platform (Railway, Render, Fly.io) to poll it.

Stripe test cards
Card numberScenarioNotes
4242 4242 4242 4242Successful paymentAny future expiry, any CVC
4000 0000 0000 0002Card declinedTests the decline error path in the form
4000 0025 0000 31553D Secure requiredTests the authentication step — approve or fail in the modal
4000 0000 0000 9995Insufficient fundsDeclined with a specific error code
4000 0000 0000 0069Expired cardDeclined at confirmation
Simulating webhook events

Use the Stripe CLI to trigger any event against your local server without making a real payment:

terminal
# Trigger a succeeded payment event (tests the registration email path)
stripe trigger payment_intent.succeeded

# Trigger a payment failure
stripe trigger payment_intent.payment_failed

# Replay a specific event from your Stripe dashboard
stripe events resend evt_1ABC...
Idempotency under replay
Replaying an event that has already been processed will be handled correctly — the webhook handler will find the existing event ID in the webhook_events table and return 200 without running any business logic again.
Testing the webhook-only path (email link flow)

The webhook path sends a registration email with a token link. To test this end-to-end in development:

  1. Configure a local mail catcher (e.g. Mailpit or Mailhog) and set SMTP_HOST=localhost with the appropriate port
  2. Submit the form with test card 4242 4242 4242 4242 — payment will succeed
  3. Before the inline confirm request completes, kill the browser tab or drop the network
  4. The webhook fires and creates a pending_registration row — check your mail catcher for the registration email
  5. Click the link in the email, set a password, and verify the account is created

Alternatively, to test without a full browser flow: run stripe trigger payment_intent.succeeded while your server is running. The webhook will attempt to resolve a customer email from the synthetic event — it may log a warning if none is found. For a more realistic test, make a real payment in test mode and replay the resulting event via stripe events resend evt_1ABC...

System guarantees
✓
No user created without successful payment
Account creation only occurs after PaymentIntent.status === "succeeded" is verified directly with Stripe's API on the server. Client-reported payment status is never trusted.
✓
Duplicate webhooks are safe
The stripe_event_id primary key on webhook_events makes duplicate processing structurally impossible. The same event ID cannot be inserted twice.
✓
Inline and webhook paths cannot create duplicate accounts
Both paths check for an existing user with the same stripe_payment_intent_id before acting. If the inline path created the account, the webhook skips the email entirely. If the webhook path created a pending_registration, the token is deleted when the user completes registration. Database UNIQUE constraints are the final safety net.
✓
No duplicate charges on retry
Idempotency keys are set on all Stripe API calls. If a network failure causes a retry of the PaymentIntent creation request, Stripe returns the existing PaymentIntent rather than creating a new charge.
✓
Webhook key rotation is handled
All stacks handle Stripe's rolling webhook signing key rotation. Events continue processing during a rotation window without manual intervention.
✓
Async payments do not create premature accounts
A processing status from an async payment method (Klarna, ACH, BACS) is not treated as success. The account is only created on payment_intent.succeeded — which fires when confirmation actually arrives, potentially hours later.
✓
Plaintext passwords never persist anywhere
bcrypt hashing occurs on the server before any database write. Plaintext is never stored anywhere — only the bcrypt hash is written to the database. Plaintext is never logged, never stored, never passed to Stripe.
✓
Malformed or tampered webhook events are rejected
Signature verification runs before any event processing. A request that fails signature verification returns 400 immediately. No data is read or written.
Specific edge case behaviour
ScenarioBehaviour
Same email submitted twiceUnique constraint on users.email prevents second account. First submission wins.
PaymentIntent ID submitted that does not belong to this userServer-side verification with Stripe confirms the PaymentIntent's customer. Mismatched customer is rejected.
Webhook arrives before inline response processedBoth handlers check for existing user before creating. First to complete creates the account. Second exits cleanly.
Payment fails after idempotency key writtenThe pending_registrations record is cleaned up by the payment_intent.payment_failed webhook handler.
Database unavailable during webhook deliveryHandler returns 500. Stripe retries per its retry schedule. Event is processed on next successful delivery.
Stripe & payment issues
Webhook signature verification is failing
The most common cause is that your framework's JSON middleware has already parsed the request body before the webhook handler sees it. Stripe's signature is computed over the raw bytes — if the body has been parsed and re-serialised, the bytes no longer match. Check that the webhook route is configured to receive the raw body. See the stack-specific note in the Webhook system section.
Webhook secret is correct but verification still fails locally
Make sure you are using the webhook secret from the Stripe CLI (whsec_... printed when you ran stripe listen), not the one from the Stripe dashboard. These are different. Dashboard secrets are for production endpoints only.
Payment succeeds but no account is created and no webhook fires
Check that the Stripe CLI is running in a separate terminal window. Check the Stripe CLI output for errors. Check that the webhook URL path matches exactly — it is case-sensitive. In the Stripe dashboard under Developers → Webhooks, check the event log for delivery attempts and any error responses from your server.
Inline confirmation works but the webhook path creates a duplicate user
This means the idempotency check is not working correctly. Verify that the stripe_event_id unique constraint exists on the webhook_events table. Check that the webhook handler is reading the user's PaymentIntent ID before creating a new record — it should exit if a user already exists for that PaymentIntent.
Database issues
Migration fails on first run
Ensure the PostgreSQL database exists before starting the app — create it with createdb regkit (or equivalent). Check that the database user has CREATE TABLE permissions. The connection string format differs per stack — see the Configuration guide for the exact format expected by each.
UNIQUE constraint violation on user creation
A user with that email or Stripe customer ID already exists. This is expected behaviour — it is the system preventing a duplicate. If you are testing and want to clear the data, delete the relevant rows from users, pending_users, and webhook_events.
Setup issues
Provisioning failed — did it use a project slot?
No. Project slots only decrement on a successful repo creation. Failed provisioning attempts are free retries. You can retry from the Projects page in your dashboard.
Still stuck?
Email hello@regkit.dev for provisioning, license, or account issues.
README.md — ASP.NET
README.md
# [PROJECT-NAME] — RegKit / ASP.NET

> Auth and payment, unified. Deferred account creation via Stripe webhooks.
> Built on RegKit — regkit.dev

---

## What this is

A production-ready ASP.NET (.NET 9) implementation of the unified auth
and payment pattern. Users complete signup and payment in a single form.
Accounts are created only after payment is confirmed server-side via Stripe.

No half-created accounts. No broken users. No duplicate charges.

---

## Stack

- **Backend:** ASP.NET (.NET 9), C#
- **Data access:** Dapper + Npgsql (raw SQL, no ORM) — repository pattern via interfaces
- **Database:** PostgreSQL — schema created automatically on startup (idempotent DDL)
- **Payments:** Stripe Elements + Stripe.net SDK (v47)
- **Auth:** Distributed memory session (HttpOnly, SameSite=Strict, Secure in production)
- **Email:** SmtpEmailService (logs to console if SMTP not configured)
- **Password hashing:** BCrypt.Net-Next (cost 12)
- **Frontend:** Vanilla HTML / JS / CSS served from wwwroot/

---

## 🚀 15-minute setup

### Prerequisites
- .NET 9 SDK
- Stripe CLI (for local webhook forwarding)
- Stripe account (test mode)

### Steps

\`\`\`bash
# 1. Download your repo. Projects are provisioned in your dashboard.

# 2. Set required environment variables
export STRIPE_SECRET_KEY=sk_test_...
export STRIPE_PUBLISHABLE_KEY=pk_test_...
export STRIPE_WEBHOOK_SECRET=whsec_...     # add after step 3
export DATABASE_CONNECTION_STRING="Host=localhost;Port=5432;Database=regkit;Username=postgres;Password=secret;"

# 3. Start webhook forwarding (separate terminal)
stripe listen --forward-to http://localhost:5000/webhook
# Copy the whsec_... value — set it as STRIPE_WEBHOOK_SECRET above

# 4. Run (schema created automatically on first startup)
dotnet run
\`\`\`

Open https://localhost:5001. Use test card \`4242 4242 4242 4242\` with
any future expiry and any CVC.

Full setup guide → https://regkit.dev/dashboard/documentation/quickstart

---

## How it works

1. User fills email, password, and card in one form
2. \`stripe.confirmPayment()\` — card goes direct to Stripe, never your server
3. JavaScript posts email, password, and PaymentIntent ID to your backend
4. Backend verifies PaymentIntent with Stripe server-side
5. Password hashed with bcrypt
6. Account created in database
7. Session issued — user lands in dashboard

Stripe webhook handles edge cases: browser closed, network timeout, async
payment methods (Klarna, ACH, BACS).

IMPORTANT: When the webhook fires without credentials (browser-closed case),
it cannot create the account directly — it has no password. Instead it creates
a pending_registration row and emails the user a time-limited token link (72h)
to set their password and complete registration. SMTP must be configured in
production for this path to work.

→ Full flow docs: https://regkit.dev/dashboard/documentation/signup-flow

---

## Key guarantees

- No account created without confirmed payment
- Duplicate webhooks produce no duplicate side effects
- No plaintext passwords ever written to disk
- Idempotency keys prevent duplicate charges on retry
- Webhook signature verified on every incoming request

→ Full guarantee list: https://regkit.dev/dashboard/documentation/edge-cases

---

## Project structure

\`\`\`
[PROJECT-NAME]-aspnet/
├── RegKit.csproj                     NuGet packages + project config
├── Program.cs                         App setup, DI registration, startup validation
├── appsettings.json                   Non-secret config (price, currency, SMTP, DB)
│
├── Controllers/
│   ├── CheckoutController.cs          ★ Core flow: PaymentIntent, verify, create account
│   ├── WebhookController.cs           ★ Stripe webhook — safety net + idempotency
│   ├── AccountController.cs           Login, logout, session check
│   └── RegisterController.cs          Webhook-path registration completion
│
├── Infrastructure/
│   ├── IUserRepository.cs             Interface for user persistence
│   ├── IEmailService.cs               Interface for transactional email
│   ├── IPendingRegistrationRepository.cs
│   ├── SmtpEmailService.cs            SMTP email implementation
│   ├── DatabaseInitialiser.cs         Creates PostgreSQL schema on first run (idempotent)
│   └── SqlProviders/
│       ├── PostgresUserRepository.cs                  ★ Active — PostgreSQL via Npgsql + Dapper
│       ├── PostgresPendingRegistrationRepository.cs   ★ Active — PostgreSQL
│       ├── SqlServerUserRepository.cs                 Alternative — includes DDL + switch instructions
│       └── MySqlUserRepository.cs                     Alternative — includes DDL + switch instructions
│
├── Models/
│   ├── User.cs                        User entity (includes Status for async payments)
│   └── PendingRegistration.cs         Webhook-path holding record
│
└── wwwroot/                           All static frontend files (HTML/CSS/JS)
    ├── index.html                     Landing page
    ├── checkout.html                  Checkout form
    ├── login.html                     Login page
    ├── dashboard.html                 Post-login dashboard
    ├── checkout/
    │   └── success.html               Payment confirmed page
    ├── register/
    │   └── complete.html              Token-based registration completion
    ├── css/
    │   └── main.css                   All styles (no framework)
    └── js/
        ├── checkout.js                ★ Stripe Elements + all frontend edge cases
        ├── login.js
        ├── dashboard.js
        └── register-complete.js
└── README.md
\`\`\`

---

## Configuration

| Env Variable | Description | Required |
|---|---|---|
| STRIPE_SECRET_KEY | Stripe secret key (sk_test_ / sk_live_) | ✓ |
| STRIPE_PUBLISHABLE_KEY | Stripe publishable key (pk_test_ / pk_live_) | ✓ |
| STRIPE_WEBHOOK_SECRET | Webhook signing secret (whsec_...) | ✓ |
| DATABASE_CONNECTION_STRING | PostgreSQL connection string | ✓ |
| Stripe:PriceInSmallestUnit | Price in pence/cents (appsettings.json) | 15000 |
| Stripe:Currency | ISO currency code (appsettings.json) | gbp |
| Stripe:PriceId | Stripe Price ID for subscriptions (appsettings.json) | — |
| App:BaseUrl | Public URL for email links (appsettings.json) | http://localhost:5000 |

Full config reference → https://regkit.dev/dashboard/documentation/configuration

---

## Safe to modify

- Frontend HTML, CSS, styling
- Post-signup redirect destination
- Email templates
- Dashboard and authenticated routes
- Stripe Price IDs and plan names

## Do not modify

- Webhook signature verification logic
- Server-side PaymentIntent verification step
- Idempotency key generation and storage
- Database unique constraints
- bcrypt work factor (do not reduce below 10)

→ Customisation guide: https://regkit.dev/dashboard/documentation/customisation

---

## Deployment

See the full deployment guide: https://regkit.dev/dashboard/documentation/deployment

Quick reference:
1. Switch to live Stripe keys (STRIPE_SECRET_KEY, STRIPE_PUBLISHABLE_KEY)
2. Register production webhook in Stripe Dashboard → Developers → Webhooks
   URL: https://[your-domain]/webhook
   Events: payment_intent.succeeded, payment_intent.payment_failed, payment_intent.processing, payment_intent.canceled
3. Copy the signing secret → set as STRIPE_WEBHOOK_SECRET
4. Set DATABASE_CONNECTION_STRING to your production PostgreSQL connection string
5. Set App:BaseUrl in appsettings.json to your production domain (used in email links)
6. Configure SMTP in appsettings.json (Email:SmtpHost etc.) for registration emails
7. Build and publish: dotnet publish -c Release
   Schema is created automatically on first startup — no migration command needed

---

## Support

- Docs: https://regkit.dev/dashboard/documentation
- Email: hello@regkit.dev
- Dashboard: https://regkit.dev/dashboard

---

Built with RegKit — regkit.dev
License: see License
README.md — Next.js
README.md
# [PROJECT-NAME] — RegKit / Next.js

> Auth and payment, unified. Deferred account creation via Stripe webhooks.
> Built on RegKit — regkit.dev

---

## What this is

A production-ready Next.js (App Router) implementation of the unified auth
and payment pattern. Users complete signup and payment in a single form.
Accounts are created only after payment is confirmed server-side via Stripe.

No half-created accounts. No broken users. No duplicate charges.

---

## Stack

- **Framework:** Next.js 16 (App Router, JavaScript)
- **Database:** PostgreSQL via `pg` (raw SQL — no ORM or Prisma)
- **Payments:** Stripe Elements + stripe npm package (v14)
- **Auth:** iron-session v8 (encrypted cookie, no server-side store)
- **Password hashing:** bcryptjs (cost 12)
- **Email:** nodemailer
- **Frontend:** React pages in src/app/ (JSX)

---

## 🚀 15-minute setup

### Prerequisites
- Node.js 18+
- pnpm (recommended) or npm
- Stripe CLI
- Stripe account (test mode)

### Steps

\`\`\`bash
# 1. Download your repo. Projects are provisioned in your dashboard
cd [PROJECT-NAME]-nextjs
pnpm install

# 2. Configure environment
cp .env.example .env.local
# Edit .env.local — add Stripe keys, DATABASE_URL, and SESSION_SECRET
# Generate SESSION_SECRET: node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"

# 3. Start webhook forwarding (schema applied automatically on first request)
stripe listen --forward-to localhost:3000/api/webhook
# Copy the whsec_... value into .env.local as STRIPE_WEBHOOK_SECRET

# 5. Run
pnpm dev
\`\`\`

Open http://localhost:3000. Use test card \`4242 4242 4242 4242\` with
any future expiry and any CVC.

Full setup guide → https://regkit.dev/dashboard/documentation/quickstart

---

## How it works

1. User fills email, password, and card in one form
2. \`stripe.confirmPayment()\` — card goes direct to Stripe, never your server
3. JavaScript posts email, password, and PaymentIntent ID to \`/api/auth/signup\`
4. API route verifies PaymentIntent with Stripe server-side
5. Password hashed with bcrypt
6. User created via raw SQL (pg)
7. iron-session cookie issued — user redirected to /checkout/success

Stripe webhook at \`/api/webhook\` handles edge cases: browser closed,
network timeout, async payment methods. Both paths share idempotency keys.

→ Full flow docs: https://regkit.dev/dashboard/documentation/signup-flow

---

## Key guarantees

- No account created without confirmed payment
- Duplicate webhooks produce no duplicate side effects
- No plaintext passwords ever written to disk
- Idempotency keys prevent duplicate charges on retry
- Webhook signature verified on every incoming request

→ Full guarantee list: https://regkit.dev/dashboard/documentation/edge-cases

---

## Project structure

\`\`\`
[PROJECT-NAME]-nextjs/
├── app/
│   ├── (auth)/
│   │   └── signup/page.tsx     # Signup page
│   └── dashboard/page.tsx      # Protected dashboard
├── app/api/
│   ├── auth/signup/route.ts    # Signup handler + PaymentIntent verify
│   ├── checkout/route.js           # GET — create PaymentIntent
│   └── webhook/route.ts        # Stripe webhook endpoint (POST /api/webhook)
├── lib/
│   ├── config.js             # Config validation (startup)
│   ├── session.js            # iron-session options
│   ├── db/index.js           # users, pending, webhookEvents query functions
│   ├── db/init.js            # ensureDb() — schema applied once
│   ├── services/email.js     # nodemailer email sender
│   └── utils/index.js        # validatePassword, generateSecureToken
├── public/                     # Frontend assets
├── .env.example                # Environment template
└── README.md
\`\`\`

---

## Environment variables

| Variable | Description | Required |
|---|---|---|
| STRIPE_SECRET_KEY | Stripe secret key (sk_test_ / sk_live_) | ✓ |
| STRIPE_PUBLISHABLE_KEY | Stripe publishable key (pk_test_ / pk_live_) | ✓ |
| STRIPE_WEBHOOK_SECRET | Webhook signing secret (whsec_...) | ✓ |
| DATABASE_URL | PostgreSQL connection string | ✓ |
| SESSION_SECRET | Signs iron-session cookies — 64-char random hex | ✓ |
| APP_BASE_URL | Public URL for registration email links | http://localhost:3000 |
| SMTP_HOST | SMTP hostname (emails logged to console if unset) | — |
| SMTP_PORT | SMTP port | 587 |
| SMTP_USER | SMTP username | — |
| SMTP_PASS | SMTP password | — |
| SMTP_FROM | Sender address for transactional emails | noreply@example.com |

Full config reference → https://regkit.dev/dashboard/documentation/configuration

---

## Safe to modify

- Signup page styling and copy
- Post-signup redirect path (SUCCESS_REDIRECT)
- Dashboard and app/ routes
- Database schema — add columns directly in src/lib/db/index.js SCHEMA constant
- Stripe Price IDs

## Do not modify

- Webhook signature verification in \`src/app/api/webhook/route.js\`
- Server-side PaymentIntent verification in the signup route
- Idempotency logic (webhookEvents.markProcessedOrSkip and per-intent user checks)
- Database unique constraints (users.email, users.stripe_payment_intent_id, etc.)
- bcrypt cost factor (do not reduce below 12)

→ Customisation guide: https://regkit.dev/dashboard/documentation/customisation

---

## Deployment 

\`\`\`
1. Run "npm run build && npm start"
2. Set environment variables
3. Register production webhook in Stripe dashboard
   → URL: https://[your-domain]/api/webhook
4. Copy live signing secret → set as STRIPE_WEBHOOK_SECRET in env
5. No migration step needed — schema applied automatically on first request
\`\`\`

Full deployment guide → https://regkit.dev/dashboard/documentation/deployment

---

## Support

- Docs: https://regkit.dev/dashboard/documentation
- Email: hello@regkit.dev
- Dashboard: https://regkit.dev/dashboard

---

Built with RegKit — regkit.dev
License: see License
README.md — FastAPI
README.md
# [PROJECT-NAME] — RegKit / FastAPI

> Auth and payment, unified. Deferred account creation via Stripe webhooks.
> Built on RegKit — regkit.dev

---

## What this is

A production-ready FastAPI (Python 3.11+) implementation of the unified auth
and payment pattern. Users complete signup and payment in a single form.
Accounts are created only after payment is confirmed server-side via Stripe.

Fully async. No half-created accounts. No broken users. No duplicate charges.
Includes correct handling of async payment methods (Klarna, ACH, BACS).

---

## Stack

- **Framework:** FastAPI 0.111 (Python 3.11+), fully async
- **Database:** PostgreSQL via asyncpg + SQLAlchemy 2.0 async engine
- **Schema management:** Alembic migrations (`alembic upgrade head`) — `create_all` is test/dev only
- **Payments:** Stripe Elements + stripe-python v10
- **Auth:** Starlette SessionMiddleware (signed cookie, 7-day TTL)
- **Password hashing:** bcrypt v4 (cost 12)
- **Email:** SMTP via smtplib (async-compatible wrapper)
- **Frontend:** Jinja2-rendered HTML shells + Vanilla JS/CSS in app/static/

---

## 🚀 15-minute setup

### Prerequisites
- Python 3.11+
- uv (recommended) or pip + venv
- Stripe CLI
- Stripe account (test mode)

### Steps

\`\`\`bash
# 1. Download your repo. Projects are provisioned in your dashboard.
cd [PROJECT-NAME]-fastapi
uv venv && source .venv/bin/activate
uv pip install -r requirements.txt

# 2. Configure environment
cp .env.example .env
# Edit .env — add your Stripe keys

# 3. Start webhook forwarding (schema created automatically on startup)
stripe listen --forward-to localhost:8000/webhook
# Copy the whsec_... value into .env as STRIPE_WEBHOOK_SECRET

# 4. Apply migrations
alembic upgrade head

# 5. Run
python run.py
\`\`\`

Open http://localhost:8000. Use test card \`4242 4242 4242 4242\` with
any future expiry and any CVC. Health check at http://localhost:8000/health.

Full setup guide → https://regkit.dev/dashboard/documentation/quickstart

---

## How it works

1. User fills email, password, and card in one form
2. \`stripe.confirmPayment()\` — card goes direct to Stripe, never your server
3. JavaScript posts email, password, and PaymentIntent ID to \`/auth/signup\`
4. FastAPI route verifies PaymentIntent with Stripe server-side (async)
5. Password hashed with bcrypt (passlib)
6. User created via SQLAlchemy async session
7. JWT issued — user redirected to dashboard

Stripe webhook at \`/webhook\` handles edge cases including async
payment methods. Klarna, ACH, and BACS payments that confirm after a delay
are handled correctly — account created only on \`payment_intent.succeeded\`.

→ Full flow docs: https://regkit.dev/dashboard/documentation/signup-flow

---

## Key guarantees

- No account created without confirmed payment (PaymentIntent verified server-side)
- Duplicate webhooks are idempotent (UNIQUE constraint on payment_intent_id)
- No plaintext passwords ever written to disk (bcrypt before any DB write)
- Async payment methods handled correctly — account only created on `succeeded`
- Webhook signature verified on every incoming request (400 on failure, logs security warning)
- App refuses to start if Stripe keys are missing or invalid (sys.exit(1))
- CSRF protection on all state-changing POST endpoints (double-submit cookie pattern)
- Session fixation prevention: session cleared before every login/registration write
- bcrypt 72-byte truncation prevented: passwords validated to max 72 chars at API layer
- Request ID middleware: every request gets a UUID in X-Request-ID for tracing

→ Full guarantee list: https://regkit.dev/dashboard/documentation/edge-cases

---

## Project structure

\`\`\`
[PROJECT-NAME]-fastapi/
app/
├── main.py                     # FastAPI app, middleware, health check, routers
├── config.py                   # pydantic-settings — all config from env vars
├── dependencies.py             # get_current_user FastAPI dependency
├── limiter.py                  # slowapi rate limiter (per-IP)
├── middleware/
│   └── csrf.py                 # Double-submit cookie CSRF middleware
├── schemas/__init__.py         # All Pydantic request + response models
├── routers/                    # HTTP layer only — thin, calls services
│   ├── checkout.py             # GET /api/checkout/intent, POST /confirm, GET /status
│   ├── account.py              # POST /api/auth/login, logout, GET /me
│   ├── register.py             # GET /api/register/status, POST /complete, /resend
│   ├── webhook.py              # POST /webhook
│   └── home.py                 # HTML page shells
├── services/                   # Business logic
│   ├── checkout_service.py     # Stripe intent + account creation
│   ├── webhook_service.py      # Webhook event handlers
│   ├── password.py             # bcrypt hash + verify
│   ├── tokens.py               # 256-bit URL-safe token generation
│   └── email_service.py        # SMTP sender
├── db/
│   ├── database.py             # SQLAlchemy async engine + ORM models
│   ├── user_repo.py
│   ├── pending_repo.py
│   └── webhook_repo.py
alembic/                        # Database migrations
├── env.py                      # Async-compatible Alembic environment
└── versions/0001_initial.py    # Initial schema migration
tests/                          # 86-test suite (SQLite in-memory, mocked Stripe)
Makefile                        # make run / lint / test / migrate
Dockerfile                      # Multi-stage production image
docker-compose.yml
.env.example
\`\`\`

---

## Environment variables

| Variable | Description | Default |
|---|---|---|
| STRIPE_SECRET_KEY | Stripe secret key — validated at startup | required |
| STRIPE_PUBLISHABLE_KEY | Stripe publishable key | required |
| STRIPE_WEBHOOK_SECRET | Webhook signing secret | required |
| DATABASE_URL | PostgreSQL asyncpg DSN: `postgresql+asyncpg://...` | required |
| SECRET_KEY | Signs Starlette session cookies | auto-generated (insecure) |
| STRIPE_PRICE_AMOUNT | Price in pence/cents | 15000 |
| STRIPE_CURRENCY | ISO currency code (lowercase) | gbp |
| APP_BASE_URL | Public URL for registration email links | http://localhost:8000 |
| SMTP_HOST | SMTP hostname (emails logged to console if unset) | — |
| SMTP_PORT | SMTP port | 587 |
| SMTP_USER | SMTP username | — |
| SMTP_PASSWORD | SMTP password | — |
| SMTP_FROM | Sender address | noreply@example.com |
| SMTP_USE_TLS | Enable STARTTLS | true |
| SESSION_HTTPS_ONLY | Set `true` in production (HTTPS-only session cookie) | false |
| LOG_LEVEL | DEBUG / INFO / WARNING / ERROR | INFO |

Full config reference → https://regkit.dev/dashboard/documentation/configuration

---

## Safe to modify

- Frontend HTML, CSS, styling
- Post-signup redirect (SUCCESS_REDIRECT)
- Alembic migrations — run `alembic revision --autogenerate -m "..."` to add columns or tables
- Email templates
- Stripe Price IDs and plan configuration

## Do not modify

- Webhook signature verification in \`routers/webhook.py\`
- Server-side PaymentIntent verification in \`routers/auth.py\`
- Idempotency key logic in webhook handler
- SQLAlchemy unique constraints on User and WebhookEvent models
- bcrypt rounds (do not reduce below 10)
- CSRF middleware exemption list (webhook is correctly exempt; do not exempt API routes)
- Session fixation `session.clear()` calls before every login write

→ Customisation guide: https://regkit.dev/dashboard/documentation/customisation

---

## Deployment

\`\`\`bash
# Docker (recommended)
docker compose up --build
docker compose exec app alembic upgrade head

# Manual
alembic upgrade head   # always run migrations before starting
uvicorn app.main:app --workers 4 --host 0.0.0.0 --port 8000

# Or use the Makefile
make migrate && make run
\`\`\`

Register production webhook in Stripe dashboard:
→ URL: https://[your-domain]/webhook
→ Copy signing secret → set as STRIPE_WEBHOOK_SECRET in production env
→ Set SESSION_HTTPS_ONLY=true in production
→ Health check endpoint: https://[your-domain]/health

Full deployment guide → https://regkit.dev/dashboard/documentation/deployment

---

## Support

- Docs: https://regkit.dev/dashboard/documentation
- Email: hello@regkit.dev
- Dashboard: https://regkit.dev/dashboard

---

Built with RegKit — regkit.dev
License: see License
Stay up to date
Updates are delivered via your private repository. Watch the repo for release notifications, or subscribe to the mailing list at updates@regkit.dev. Customers with an active license always retain perpetual rights to the version they hold — updates are additive.
--> -->
v1.0.0 01 May 2026 Initial release
.NET 9 Next.js FastAPI
ADDED
  • Initial launch of all three stacks — .NET 9, Next.js 14, FastAPI.
  • Deferred account creation pattern, Stripe Checkout integration, webhook handler with idempotency, email on confirmation, protected route middleware.
  • Full documentation, quickstart guides, and deployment guides for Vercel, Railway, and Render.