| Stack | Language | Notes |
|---|---|---|
| ASP.NET | C# / .NET 9 | Dapper + Npgsql, BCrypt.Net, cookie sessions, PostgreSQL |
| Next.js | JavaScript | App Router, API routes, iron-session, raw pg, PostgreSQL |
| FastAPI | Python 3.11+ | Async SQLAlchemy 2.0, asyncpg, Starlette sessions, PostgreSQL |
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)
Your repo is provisioned via the dashboard. After generating a project, your repo can be downloaded.
The app reads Stripe keys exclusively from environment variables — not from appsettings.json. Set them in your shell before running:
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:
{
"Stripe": {
"PriceInSmallestUnit": 15000, // £150.00 — edit as needed
"Currency": "gbp",
"PriceId": "price_YOUR_PRICE_ID"
},
"App": { "BaseUrl": "http://localhost:5000" }
}
Open a second terminal and run the Stripe CLI. Copy the whsec_... value it prints and set it as STRIPE_WEBHOOK_SECRET.
stripe listen --forward-to http://localhost:5000/webhook
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.
- 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
Your repo is provisioned via the dashboard. After generating a project, your repo can be downloaded.
cp .env.example .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
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.
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.
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.
- Python 3.11+ — python --version
- uv (recommended) or pip + venv
- Stripe CLI — for local webhook forwarding
- A Stripe account with test mode enabled
Your repo is provisioned via the dashboard. After generating a project, your repo can be downloaded.
cp .env.example .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
Apply the database migration first (production always uses Alembic — never create_all), then start webhook forwarding in a second terminal:
alembic upgrade head
stripe listen --forward-to localhost:8000/webhook
Copy the whsec_... value into your .env as STRIPE_WEBHOOK_SECRET.
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.
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.
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.
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.
| Decision | Rationale |
|---|---|
| Stripe Elements, not Checkout | Keeps everything on one page, backend in the loop, no JWT credential encoding needed |
| Server-side PaymentIntent verification | Closes the fake payment vector — client is never trusted |
| bcrypt on the server | Plaintext credentials never touch the database or third-party services |
| Idempotency keys on all Stripe calls | Safe retries without duplicate charges |
| Webhook signature verification | All incoming webhook events validated against Stripe's signature before processing |
| Event storage before processing | Webhook events written to the database before any business logic runs — enables replay and audit |
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.
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.
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.
| Event | Handler action |
|---|---|
| payment_intent.succeeded | Check for existing user — if none, resolve customer email from Stripe, create pending_registration row, send registration email with token link |
| payment_intent.payment_failed | Log failure details; optionally send failure notification email to the customer; clean up any pending_registration row tied to this PaymentIntent |
| payment_intent.processing | Log that async payment is in progress (Klarna, ACH, BACS). Account creation deferred until succeeded fires. |
| payment_intent.canceled | Clean up any pending_registration row tied to this PaymentIntent |
| charge.dispute.created | Logged at warning level with dispute ID, amount, and reason for manual follow-up |
| (all others) | Logged and 200 returned — Stripe stops retrying |
| Variable | Description | Default |
|---|---|---|
| STRIPE_SECRET_KEYrequired | Stripe secret key — sk_test_ or sk_live_. Validated against the Stripe API at startup. | — |
| STRIPE_PUBLISHABLE_KEYrequired | Stripe publishable key — pk_test_ or pk_live_. Sent to the browser to initialise Stripe Elements. | — |
| STRIPE_WEBHOOK_SECRETrequired | Webhook signing secret from stripe listen (dev) or Stripe Dashboard (prod). | — |
| DATABASE_URLrequired | PostgreSQL asyncpg DSN: postgresql+asyncpg://user:pass@host:5432/dbname | localhost/regkit |
| SECRET_KEYrequired | Signs Starlette session cookies. Generate with python -c "import secrets; print(secrets.token_hex(32))" | auto-generated (insecure) |
| STRIPE_PRICE_AMOUNToptional | Price in smallest currency unit (pence/cents). 15000 = £150.00 or $150.00. | 15000 |
| STRIPE_CURRENCYoptional | ISO 4217 currency code (lowercase): gbp, usd, eur. | gbp |
| APP_BASE_URLoptional | Public URL of your app. Used in registration email links (webhook path). | http://localhost:8000 |
| SMTP_HOSToptional | SMTP hostname. If unset, emails are logged to console — useful for development. | — |
| SMTP_PORToptional | SMTP port. | 587 |
| SMTP_USERoptional | SMTP username. | — |
| SMTP_PASSWORDoptional | SMTP password. | — |
| SMTP_FROMoptional | Sender address for transactional emails. | noreply@example.com |
| SMTP_USE_TLSoptional | Enable STARTTLS. Set false for local mailhog / mailpit. | true |
| SESSION_HTTPS_ONLYoptional | Set true in production so the session cookie is HTTPS-only. Leave false for local HTTP development. | false |
| LOG_LEVELoptional | DEBUG / INFO / WARNING / ERROR | INFO |
| Variable | Description | Default |
|---|---|---|
| STRIPE_SECRET_KEYrequired | Stripe secret key — sk_test_ or sk_live_. | — |
| STRIPE_PUBLISHABLE_KEYrequired | Stripe publishable key — pk_test_ or pk_live_. Used by the checkout page client-side. | — |
| STRIPE_WEBHOOK_SECRETrequired | Webhook signing secret. Config validation checks for placeholder values at startup. | — |
| DATABASE_URLrequired | PostgreSQL connection string: postgresql://user:pass@host:5432/dbname. SSL is enabled automatically in production. | — |
| SESSION_SECRETrequired | Signs iron-session cookies. Generate with node -e "console.log(require('crypto').randomBytes(64).toString('hex'))". Must be 32+ chars. | — |
| APP_BASE_URLoptional | Base URL for registration email links (webhook path). Must be the public URL in production. | http://localhost:3000 |
| SMTP_HOSToptional | SMTP hostname for transactional emails. Emails are logged to console if unset. | — |
| SMTP_PORToptional | SMTP port. | 587 |
| SMTP_USERoptional | SMTP username. | — |
| SMTP_PASSoptional | SMTP password. | — |
| SMTP_FROMoptional | Sender address for transactional emails. | noreply@example.com |
| Variable / Key | Description | Default |
|---|---|---|
| STRIPE_SECRET_KEYrequired | Stripe secret key (environment variable). Startup exits with a clear error message if missing. | — |
| STRIPE_PUBLISHABLE_KEYrequired | Stripe publishable key (environment variable). | — |
| STRIPE_WEBHOOK_SECRETrequired | Webhook signing secret (environment variable). | — |
| DATABASE_CONNECTION_STRINGrequired | PostgreSQL connection string via environment variable, or Database:ConnectionString in appsettings.json as fallback. | — |
| Stripe:PriceInSmallestUnitoptional | Price in pence/cents. Set in appsettings.json. | 15000 |
| Stripe:Currencyoptional | ISO 4217 currency code (lowercase). Set in appsettings.json. | gbp |
| Stripe:PriceIdoptional | Stripe Price ID for subscription creation. Set in appsettings.json. | price_YOUR_PRICE_ID |
| App:BaseUrloptional | Public URL — used in registration email links. Set in appsettings.json. | http://localhost:5000 |
| Email:SmtpHostoptional | SMTP hostname. Set in appsettings.json. | localhost |
| Email:SmtpPortoptional | SMTP port. | 25 |
| Email:SmtpUser / SmtpPasswordoptional | SMTP credentials. Leave blank for unauthenticated local relay. | — |
| Email:FromAddressoptional | Sender address for transactional emails. | noreply@example.com |
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.
- Create your product and price in the Stripe dashboard
- Update STRIPE_PRICE_AMOUNT (FastAPI / Next.js) or Stripe:PriceInSmallestUnit (.NET) to the new amount
- 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.
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.
- 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
- 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
Trials are managed at the Stripe subscription level. To add a trial period:
- Set a trial_period_days value on the Stripe Price in your dashboard, or pass it when creating the Subscription in your backend
- Update your subscription creation code to include trial_end or trial_period_days in the Stripe API call
- 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.
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:
/** * 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:
// ~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:
// 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
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:
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:
// ~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:
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++;
Step 1 — Server validation (authoritative). Password validation lives in two controllers. Open 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):
// 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:
// ~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:
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++;
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.
dotnet add package Microsoft.Data.SqlClient
Replace the PostgreSQL connection string with a SQL Server connection string:
DATABASE_CONNECTION_STRING="Server=localhost;Database=regkit;User Id=sa;Password=Your_password123;"
The default implementation uses Npgsql. Replace it with SqlConnection.
using Microsoft.Data.SqlClient; using var connection = new SqlConnection(connectionString); await connection.OpenAsync();
Dapper usage remains unchanged — only the connection type differs.
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.
- 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.
pnpm add better-sqlite3
Replace the PostgreSQL connection string with a local SQLite file:
DATABASE_URL=file:./regkit.db
The default implementation uses pg. Replace it with a SQLite client.
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.
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.
- 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).
uv pip install aiosqlite
Replace the PostgreSQL connection string with a SQLite file:
DATABASE_URL=sqlite+aiosqlite:///./regkit.db
Adjust the SQLAlchemy engine to support SQLite:
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.
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.
- 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.
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.
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.
| Card number | Scenario | Notes |
|---|---|---|
| 4242 4242 4242 4242 | Successful payment | Any future expiry, any CVC |
| 4000 0000 0000 0002 | Card declined | Tests the decline error path in the form |
| 4000 0025 0000 3155 | 3D Secure required | Tests the authentication step — approve or fail in the modal |
| 4000 0000 0000 9995 | Insufficient funds | Declined with a specific error code |
| 4000 0000 0000 0069 | Expired card | Declined at confirmation |
Use the Stripe CLI to trigger any event against your local server without making a real payment:
# 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...
The webhook path sends a registration email with a token link. To test this end-to-end in development:
- Configure a local mail catcher (e.g. Mailpit or Mailhog) and set SMTP_HOST=localhost with the appropriate port
- Submit the form with test card 4242 4242 4242 4242 — payment will succeed
- Before the inline confirm request completes, kill the browser tab or drop the network
- The webhook fires and creates a pending_registration row — check your mail catcher for the registration email
- 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...
| Scenario | Behaviour |
|---|---|
| Same email submitted twice | Unique constraint on users.email prevents second account. First submission wins. |
| PaymentIntent ID submitted that does not belong to this user | Server-side verification with Stripe confirms the PaymentIntent's customer. Mismatched customer is rejected. |
| Webhook arrives before inline response processed | Both handlers check for existing user before creating. First to complete creates the account. Second exits cleanly. |
| Payment fails after idempotency key written | The pending_registrations record is cleaned up by the payment_intent.payment_failed webhook handler. |
| Database unavailable during webhook delivery | Handler returns 500. Stripe retries per its retry schedule. Event is processed on next successful delivery. |
# [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
# [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
# [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
- 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.