# Waitlist Mode
Waitlist mode allows you to validate market demand before building features. Set `WAITLIST_MODE=true` and selected routes will show waitlist signup forms instead of the normal flow. Emails are captured to a database table and automatically segmented into per-blueprint Resend audiences for targeted launch campaigns.
## Use Cases
- **Pre-launch**: Gauge interest before going live
- **Feature validation**: Test new features (e.g., business plan export) before implementation
- **Market segment testing**: Validate demand for supplier tiers or new markets
- **Lean startup smoke test**: Capture leads without building full functionality
## Quick Start
### 1. Enable waitlist mode
```bash
# Add to .env
WAITLIST_MODE=true
RESEND_API_KEY=re_xyz123 # Optional: audiences created automatically per blueprint
```
### 2. Run migration (if not already done)
```bash
uv run python -m padelnomics.migrations.migrate
```
This creates the `waitlist` table with the schema:
```sql
CREATE TABLE waitlist (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL,
intent TEXT NOT NULL, -- e.g., "signup", "supplier", "free", "pro"
source TEXT, -- Optional: campaign source
plan TEXT, -- Optional: plan name
ip_address TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
UNIQUE(email, intent) -- Same email can sign up for different intents
);
CREATE INDEX idx_waitlist_email ON waitlist(email);
```
### 3. Test it
Visit any gated route:
- http://localhost:5000/auth/signup → entrepreneur waitlist
- http://localhost:5000/suppliers/signup → supplier waitlist
- http://localhost:5000/planner/export → business plan export waitlist
Submit an email → see confirmation page → check the `waitlist` table:
```bash
uv run python -c "
import sqlite3
conn = sqlite3.connect('data/app.db')
rows = conn.execute('SELECT * FROM waitlist ORDER BY created_at DESC LIMIT 10').fetchall()
for row in rows:
print(row)
"
```
### 4. Turn off waitlist mode
```bash
# In .env
WAITLIST_MODE=false
```
Restart the app. All routes revert to normal signup/checkout flows.
---
## Architecture
### Two Abstractions
1. **`@waitlist_gate(template, **context)` decorator** — intercepts GET requests
2. **`capture_waitlist_email(email, intent, plan, email_intent)` helper** — handles DB + email + Resend
### How It Works
#### GET Requests (Decorator)
The `@waitlist_gate` decorator intercepts GET requests when `WAITLIST_MODE=true` and renders a waitlist template instead of the normal page.
```python
from padelnomics.core import waitlist_gate
@bp.route("/signup", methods=["GET", "POST"])
@csrf_protect
@waitlist_gate("waitlist.html", plan=lambda: request.args.get("plan", "free"))
async def signup():
"""Signup page."""
# This code only runs when WAITLIST_MODE=false or for POST requests
...
```
**How it works:**
- Checks `config.WAITLIST_MODE` and `request.method`
- If mode is enabled AND method is GET → renders the waitlist template
- Otherwise → passes through to the wrapped function
- Context variables can be callables (evaluated at request time) or static values
**Why POST passes through:**
- Routes need to handle waitlist form submissions
- Each route controls its own POST logic (validation, error handling, success template)
#### POST Requests (Helper)
The `capture_waitlist_email()` helper handles the database insertion, email queueing, and Resend integration.
```python
from padelnomics.core import capture_waitlist_email
async def signup():
if config.WAITLIST_MODE and request.method == "POST":
form = await request.form
email = form.get("email", "").strip().lower()
if not email or "@" not in email:
await flash("Please enter a valid email address.", "error")
return redirect(url_for("auth.signup"))
await capture_waitlist_email(email, intent="signup")
return await render_template("waitlist_confirmed.html", email=email)
# Normal signup flow below...
```
**What it does:**
1. Inserts email into `waitlist` table with `INSERT OR IGNORE` (idempotent)
2. Returns `True` if new row inserted, `False` if duplicate
3. Enqueues `send_waitlist_confirmation` email task (only for new signups)
4. Adds email to Resend audience if `RESEND_AUDIENCE_WAITLIST` is set
5. All errors are handled silently — user always sees success page
**Parameters:**
- `email` (str) — Email address to capture
- `intent` (str) — Intent value stored in database (e.g., "signup", "supplier")
- `plan` (str, optional) — Plan name stored in database (e.g., "supplier_pro")
- `email_intent` (str, optional) — Intent value for email confirmation (defaults to `intent`)
**Why `email_intent`?**
Suppliers need different intent values for DB vs email:
- **Database**: `intent="supplier"` (all suppliers grouped together)
- **Email**: `intent="supplier_pro"` (plan-specific messaging)
```python
await capture_waitlist_email(
email,
intent="supplier", # DB: all suppliers
plan="supplier_pro", # DB: plan name
email_intent="supplier_pro" # Email: plan-specific
)
```
---
## Adding Waitlist to New Routes
### Step 1: Import the abstractions
```python
from ..core import capture_waitlist_email, waitlist_gate
```
### Step 2: Add the decorator
```python
@bp.route("/your-route", methods=["GET", "POST"])
@csrf_protect
@waitlist_gate("your_waitlist_template.html", custom_var=lambda: some_value())
async def your_route():
...
```
### Step 3: Handle POST in your route
```python
async def your_route():
if config.WAITLIST_MODE and request.method == "POST":
form = await request.form
email = form.get("email", "").strip().lower()
# Validate email
if not email or "@" not in email:
await flash("Please enter a valid email address.", "error")
return redirect(url_for("your_blueprint.your_route"))
# Capture to waitlist
await capture_waitlist_email(email, intent="your_intent")
# Show confirmation
return await render_template("your_confirmation.html", email=email)
# Normal flow below...
...
```
### Step 4: Create templates
1. **Waitlist form** (`your_waitlist_template.html`)
```html
{% extends "base.html" %}
{% block content %}
Join the Waitlist
We're launching soon! Enter your email to get early access.
{% endblock %}
```
2. **Confirmation page** (`your_confirmation.html`)
```html
{% extends "base.html" %}
{% block content %}
You're on the list!
We've sent a confirmation to {{ email }}.
We'll notify you when we launch. In the meantime, follow us on social media for updates.
{% endblock %}
```
---
## Email Confirmations
Waitlist confirmations are sent via the `send_waitlist_confirmation` worker task.
### How it works
1. Route calls `capture_waitlist_email()`
2. Helper enqueues: `await enqueue("send_waitlist_confirmation", {"email": email, "intent": intent})`
3. Worker picks up task and calls `handle_send_waitlist_confirmation()`
4. Email sent via Resend (or printed to console in dev)
### Email content
Defined in `src/padelnomics/worker.py`:
```python
async def handle_send_waitlist_confirmation(data):
email = data["email"]
intent = data.get("intent", "signup")
# Intent-specific messaging
if intent == "signup" or intent.startswith("free") or intent.startswith("starter"):
subject = "Thanks for joining the Padelnomics waitlist!"
body = "We're launching soon..."
elif intent.startswith("supplier"):
plan_name = intent.replace("supplier_", "").title()
subject = f"You're on the list for Padelnomics {plan_name}"
body = f"Thanks for your interest in the {plan_name} plan..."
else:
subject = "You're on the Padelnomics waitlist"
body = "We'll notify you when we launch..."
await send_email(
to=email,
subject=subject,
html=body,
from_addr=EMAIL_ADDRESSES["transactional"]
)
```
### Customizing emails
Edit `handle_send_waitlist_confirmation()` in `worker.py` to:
- Add new intent-specific messaging
- Include plan details or pricing
- Add CTA buttons or links
- Personalize based on source/plan
---
## Resend Integration
Audiences are created automatically per blueprint — no configuration needed beyond `RESEND_API_KEY`. The first signup from each blueprint creates a named audience in Resend and caches its ID in the `resend_audiences` database table. Subsequent signups from the same blueprint skip the API call and use the cached ID.
### Audience names
| Route | Blueprint | Resend audience |
|-------|-----------|-----------------|
| `/auth/signup` | `auth` | `waitlist-auth` |
| `/suppliers/signup/waitlist` | `suppliers` | `waitlist-suppliers` |
| `/planner/export` | `planner` | `waitlist-planner` |
Adding `@waitlist_gate` to any new blueprint automatically creates its own audience on first signup.
### Setup
Set `RESEND_API_KEY` in `.env` — no audience IDs needed:
```bash
RESEND_API_KEY=re_xyz123
```
### How it works
```python
# Derives audience name from current request's blueprint
blueprint = request.blueprints[0] if request.blueprints else "default"
audience_name = f"waitlist-{blueprint}"
# Lazy-creates audience on first use, caches ID in resend_audiences table
audience_id = await _get_or_create_resend_audience(audience_name)
if audience_id:
resend.Contacts.create({"email": email, "audience_id": audience_id})
```
**Silent failures:**
- `Audiences.create` error → `audience_id` is None, contact skipped
- `Contacts.create` error → ignored
- Duplicate emails (already in audience) → ignored
- API rate limits → ignored
Waitlist signups always succeed even if Resend is down.
### Launch campaign
When ready to launch:
1. Go to Resend dashboard → Audiences → select the segment (e.g., `waitlist-suppliers`)
2. Create a broadcast email: "We're live! Here's your early access..."
3. Send to the audience
---
## Testing
### Unit Tests
Tests are in `tests/test_waitlist.py`:
```bash
uv run pytest tests/test_waitlist.py -v
```
**Test coverage:**
- Configuration: `WAITLIST_MODE` flag exists and can be toggled
- Migration: `waitlist` table schema, constraints, indexes
- Worker: Email confirmation task content and addressing
- Auth routes: GET shows waitlist, POST captures email
- Supplier routes: Waitlist form, email capture, plan-specific messaging
- Planner routes: Export waitlist gate
- Decorator: GET intercept, POST passthrough, callable context
- Helper: DB operations, email queueing, Resend integration, error handling
- Edge cases: Duplicate emails, invalid emails, DB errors, Resend errors
- Integration: Full flows from GET → POST → DB → email
### Manual Testing
1. **Enable waitlist mode:**
```bash
# In .env
WAITLIST_MODE=true
```
2. **Visit gated routes:**
- http://localhost:5000/auth/signup?plan=starter
- http://localhost:5000/suppliers/signup?plan=supplier_pro
- http://localhost:5000/planner/export
3. **Submit emails:**
- Valid email → confirmation page
- Invalid email → error message
- Duplicate email → confirmation page (no error)
4. **Check database:**
```bash
uv run python -c "
import sqlite3
conn = sqlite3.connect('data/app.db')
rows = conn.execute('SELECT * FROM waitlist ORDER BY created_at DESC').fetchall()
for row in rows:
print(row)
"
```
5. **Check worker logs:**
- Start worker: `uv run python -m padelnomics.worker`
- Submit email via form
- Look for: `[TASK] Processing send_waitlist_confirmation...`
- Email should print to console (if no Resend key) or send via Resend
6. **Disable waitlist mode:**
```bash
# In .env
WAITLIST_MODE=false
```
- Restart app
- Visit routes → should see normal signup/checkout flows
---
## Database Schema
### `waitlist` table
```sql
CREATE TABLE waitlist (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL,
intent TEXT NOT NULL,
source TEXT,
plan TEXT,
ip_address TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
UNIQUE(email, intent)
);
CREATE INDEX idx_waitlist_email ON waitlist(email);
```
**Columns:**
- `id` — Auto-incrementing primary key
- `email` — Email address (lowercase, trimmed)
- `intent` — Signup intent (e.g., "signup", "supplier", "free", "pro")
- `source` — Optional campaign source (e.g., "facebook_ad", "twitter")
- `plan` — Optional plan name (e.g., "supplier_pro", "business_plan")
- `ip_address` — Request IP address (for spam detection)
- `created_at` — Timestamp (ISO 8601)
**Constraints:**
- `UNIQUE(email, intent)` — Same email can sign up for multiple intents
- Example: `user@example.com` can be on both "signup" and "supplier" waitlists
- Duplicate submissions for same email+intent are ignored (idempotent)
**Indexes:**
- `idx_waitlist_email` — Fast lookups by email
### Queries
**Count total signups:**
```sql
SELECT COUNT(*) FROM waitlist;
```
**Count by intent:**
```sql
SELECT intent, COUNT(*) as count
FROM waitlist
GROUP BY intent
ORDER BY count DESC;
```
**Recent signups:**
```sql
SELECT * FROM waitlist
ORDER BY created_at DESC
LIMIT 50;
```
**Duplicates (same email, different intents):**
```sql
SELECT email, COUNT(*) as count
FROM waitlist
GROUP BY email
HAVING count > 1;
```
**Export for Resend:**
```sql
SELECT DISTINCT email FROM waitlist;
```
---
## Configuration Reference
### Environment Variables
| Variable | Type | Default | Description |
|----------|------|---------|-------------|
| `WAITLIST_MODE` | bool | `false` | Enable waitlist gates on routes |
| `RESEND_API_KEY` | string | `""` | Resend API key — audiences created automatically per blueprint |
### Usage
```bash
# .env
WAITLIST_MODE=true
RESEND_API_KEY=re_xyz123
# No audience IDs needed — created automatically on first signup
```
### Config Access
```python
from padelnomics.core import config
if config.WAITLIST_MODE:
# Show waitlist
else:
# Normal flow
```
---
## Best Practices
### 1. Keep waitlist templates simple
- Single form field (email)
- Clear value proposition
- No extra fields (friction kills conversions)
- Mobile-friendly
### 2. Always show confirmation page
- Don't redirect to homepage
- Show success message with email address
- Set expectations (when will they hear from you?)
- Add social proof or testimonials
### 3. Test with real emails
- Use your own email to verify full flow
- Check spam folder for confirmations
- Test with different email providers (Gmail, Outlook, ProtonMail)
### 4. Monitor signups
```sql
-- Daily signups
SELECT DATE(created_at) as date, COUNT(*) as signups
FROM waitlist
GROUP BY DATE(created_at)
ORDER BY date DESC
LIMIT 30;
-- Conversion rate (if tracking sources)
SELECT source, COUNT(*) as signups
FROM waitlist
GROUP BY source
ORDER BY signups DESC;
```
### 5. Plan your launch
- Set a signup goal (e.g., "100 signups in 30 days")
- Promote waitlist via social media, ads, content
- Send weekly updates to waitlist (build excitement)
- Launch when you hit goal or deadline
### 6. Export before launch
```sql
-- Export all emails for Resend broadcast
SELECT DISTINCT email FROM waitlist;
```
Save as CSV, upload to Resend audience, send launch announcement.
### 7. Clean up after launch
```sql
-- Archive waitlist signups (optional)
CREATE TABLE waitlist_archive AS SELECT * FROM waitlist;
-- Clear table (optional - only if you want to reuse for another feature)
DELETE FROM waitlist;
```
---
## Troubleshooting
### Waitlist page not showing
**Symptoms:** Visit `/auth/signup` → see normal signup form
**Causes:**
1. `WAITLIST_MODE` not set to `true` in `.env`
2. `.env` changes not loaded (need to restart app)
3. Using wrong environment (production vs dev)
**Fix:**
```bash
# Check config
uv run python -c "from padelnomics.core import config; print(config.WAITLIST_MODE)"
# Should print: True
# If False, check .env and restart:
# WAITLIST_MODE=true
```
### Emails not captured in database
**Symptoms:** Submit form → confirmation page shows, but no row in `waitlist` table
**Causes:**
1. Database migration not run
2. DB connection error
3. Email validation rejecting input
**Fix:**
```bash
# Check if table exists
uv run python -c "
import sqlite3
conn = sqlite3.connect('data/app.db')
tables = [r[0] for r in conn.execute(\"SELECT name FROM sqlite_master WHERE type='table'\").fetchall()]
print('waitlist' in tables)
"
# If False, run migration:
uv run python -m padelnomics.migrations.migrate
```
### Confirmation emails not sending
**Symptoms:** Email captured in DB, but no confirmation email sent/printed
**Causes:**
1. Worker not running
2. Task queue error
3. Resend API error
**Fix:**
```bash
# Check worker is running
ps aux | grep "python -m padelnomics.worker"
# Start if not running
uv run python -m padelnomics.worker
# Check worker logs for errors
# Look for: [TASK] Processing send_waitlist_confirmation...
```
### Resend audience not adding contacts
**Symptoms:** Emails captured, confirmations sent, but not appearing in Resend audience
**Causes:**
1. `RESEND_API_KEY` not set
2. `Audiences.create` API error on first signup (check logs)
3. Resend API rate limit
4. Contacts already in audience (silent duplicate)
**Fix:**
```bash
# Check config
uv run python -c "
from padelnomics.core import config
print('API key:', 'Set' if config.RESEND_API_KEY else 'Not set')
"
# Check resend_audiences table for cached IDs
uv run python -c "
import sqlite3
conn = sqlite3.connect('data/app.db')
rows = conn.execute('SELECT * FROM resend_audiences').fetchall()
for row in rows:
print(row)
"
```
### Decorator not intercepting GET requests
**Symptoms:** Waitlist mode enabled, but route shows normal page
**Causes:**
1. Decorator applied in wrong order
2. Missing import
3. Decorator syntax error
**Fix:**
```python
# Correct order:
@bp.route("/route", methods=["GET", "POST"])
@csrf_protect
@waitlist_gate("template.html")
async def route():
...
# NOT:
@waitlist_gate("template.html")
@csrf_protect # <- csrf must come after route decorator
@bp.route("/route", methods=["GET", "POST"])
```
---
## Migration Guide
If you already have waitlist logic copy-pasted across routes, here's how to migrate:
### Before (duplicated code)
```python
@bp.route("/signup", methods=["GET", "POST"])
@csrf_protect
async def signup():
if config.WAITLIST_MODE:
if request.method == "GET":
return await render_template("waitlist.html")
# POST - capture email
form = await request.form
email = form.get("email", "").strip().lower()
if not email or "@" not in email:
await flash("Invalid email", "error")
return redirect(url_for("auth.signup"))
# Insert into DB
await execute(
"INSERT OR IGNORE INTO waitlist (email, intent, ip_address) VALUES (?, ?, ?)",
(email, "signup", request.remote_addr)
)
# Enqueue email
await enqueue("send_waitlist_confirmation", {"email": email, "intent": "signup"})
# Add to Resend (old: manual audience ID required)
if config.RESEND_AUDIENCE_WAITLIST:
try:
resend.Contacts.create(...)
except:
pass
return await render_template("waitlist_confirmed.html", email=email)
# Normal flow...
```
### After (using decorator + helper)
```python
from ..core import capture_waitlist_email, waitlist_gate
@bp.route("/signup", methods=["GET", "POST"])
@csrf_protect
@waitlist_gate("waitlist.html")
async def signup():
if config.WAITLIST_MODE and request.method == "POST":
form = await request.form
email = form.get("email", "").strip().lower()
if not email or "@" not in email:
await flash("Invalid email", "error")
return redirect(url_for("auth.signup"))
await capture_waitlist_email(email, intent="signup")
return await render_template("waitlist_confirmed.html", email=email)
# Normal flow...
```
**Lines saved:** 40+ → 10 (75% reduction)
---
## FAQ
### Q: Can I use waitlist mode in production?
**A:** Yes! That's the point. Enable it when you want to validate demand before building features.
### Q: What happens to existing users when I enable waitlist mode?
**A:** Logged-in users can still access the app normally. Waitlist gates only apply to unauthenticated routes (signup, supplier signup, export). Consider adding a "coming soon" banner for logged-in users if gating features they can see.
### Q: Can I customize the waitlist email template?
**A:** Yes. Edit `handle_send_waitlist_confirmation()` in `src/padelnomics/worker.py`. You can change subject, body, sender, and add HTML formatting.
### Q: Can I add custom fields to the waitlist form?
**A:** Yes, but keep it minimal (email + 1-2 fields max). Add columns to the `waitlist` table via a migration, then update `capture_waitlist_email()` and your route to capture them.
### Q: How do I prevent spam signups?
**A:** Current measures:
- CSRF token required
- Email validation (must contain `@`)
- IP address captured (for manual review)
- Rate limiting (inherited from app-wide rate limits)
Additional measures:
- Add honeypot field to form
- Add Cloudflare Turnstile captcha
- Add email verification (double opt-in)
### Q: Can I A/B test waitlist messaging?
**A:** Yes! Use the existing `@ab_test` decorator:
```python
@bp.route("/signup", methods=["GET", "POST"])
@csrf_protect
@ab_test("waitlist_messaging", variants=("short", "long"))
@waitlist_gate("waitlist.html", variant=lambda: g.ab_variant)
async def signup():
...
```
Then create two template versions based on `variant`.
### Q: How do I export the waitlist?
**A:**
```bash
uv run python -c "
import sqlite3, csv
conn = sqlite3.connect('data/app.db')
rows = conn.execute('SELECT email, intent, created_at FROM waitlist ORDER BY created_at').fetchall()
with open('waitlist_export.csv', 'w', newline='') as f:
writer = csv.writer(f)
writer.writerow(['email', 'intent', 'created_at'])
writer.writerows(rows)
print('Exported', len(rows), 'emails to waitlist_export.csv')
"
```
### Q: Can I use waitlist mode for some routes but not others?
**A:** Yes. `WAITLIST_MODE` is a global flag, but you can add conditional logic:
```python
# Only gate this route if WAITLIST_MODE is enabled
@waitlist_gate("waitlist.html") if config.WAITLIST_MODE else lambda f: f
async def route():
...
```
Or create a feature-specific flag:
```python
# core.py
class Config:
WAITLIST_MODE_EXPORT: bool = os.getenv("WAITLIST_MODE_EXPORT", "false").lower() == "true"
# routes.py
@waitlist_gate("export_waitlist.html") if config.WAITLIST_MODE_EXPORT else lambda f: f
async def export():
...
```
---
## Summary
Waitlist mode is a lean, no-frills feature validation tool. Enable it with a single env var, capture emails with two abstractions, and disable it when you're ready to launch. No dependencies, no SaaS subscriptions, no complexity.
**Key files:**
- `src/padelnomics/core.py` — `waitlist_gate()` decorator + `capture_waitlist_email()` helper
- `src/padelnomics/auth/routes.py` — Example: entrepreneur signup
- `src/padelnomics/suppliers/routes.py` — Example: supplier signup with plan-specific messaging
- `src/padelnomics/planner/routes.py` — Example: feature gate (export)
- `src/padelnomics/worker.py` — Email confirmation task handler
- `tests/test_waitlist.py` — Comprehensive test coverage
**Architecture:**
- SQLite table with email, intent, plan, ip_address
- Decorator intercepts GET requests, routes handle POST
- Helper handles DB + email + Resend (all silent failures)
- Worker sends confirmations via `send_waitlist_confirmation` task
**Usage:**
1. Set `WAITLIST_MODE=true`
2. Add `@waitlist_gate("template.html")` to routes
3. Call `await capture_waitlist_email(email, intent)` in POST handler
4. Create waitlist + confirmation templates
5. Test, promote, monitor signups
6. Export emails, launch, disable waitlist mode