Removes RESEND_AUDIENCE_WAITLIST env var. capture_waitlist_email() now derives audience name from request.blueprints[0] (e.g. waitlist-auth, waitlist-suppliers), lazily creates it via Resend API on first signup, and caches the ID in a new resend_audiences table. Zero config beyond RESEND_API_KEY — adding @waitlist_gate to any new blueprint auto-creates its audience on first use. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
25 KiB
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
# Add to .env
WAITLIST_MODE=true
RESEND_API_KEY=re_xyz123 # Optional: audiences created automatically per blueprint
2. Run migration (if not already done)
uv run python -m padelnomics.migrations.migrate
This creates the waitlist table with the schema:
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:
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
# In .env
WAITLIST_MODE=false
Restart the app. All routes revert to normal signup/checkout flows.
Architecture
Two Abstractions
@waitlist_gate(template, **context)decorator — intercepts GET requestscapture_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.
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_MODEandrequest.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.
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:
- Inserts email into
waitlisttable withINSERT OR IGNORE(idempotent) - Returns
Trueif new row inserted,Falseif duplicate - Enqueues
send_waitlist_confirmationemail task (only for new signups) - Adds email to Resend audience if
RESEND_AUDIENCE_WAITLISTis set - All errors are handled silently — user always sees success page
Parameters:
email(str) — Email address to captureintent(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 tointent)
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)
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
from ..core import capture_waitlist_email, waitlist_gate
Step 2: Add the decorator
@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
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
- Waitlist form (
your_waitlist_template.html)
{% extends "base.html" %}
{% block content %}
<div class="max-w-md mx-auto mt-12">
<h1 class="text-3xl font-bold mb-4">Join the Waitlist</h1>
<p class="text-gray-600 mb-8">
We're launching soon! Enter your email to get early access.
</p>
<form method="post" class="space-y-4">
<input type="hidden" name="csrf_token" value="{{ get_csrf_token() }}">
<div>
<label for="email" class="block text-sm font-medium mb-2">Email</label>
<input
type="email"
name="email"
id="email"
required
class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="you@example.com"
>
</div>
<button
type="submit"
class="w-full bg-blue-600 text-white py-2 px-4 rounded-lg hover:bg-blue-700"
>
Join Waitlist
</button>
</form>
</div>
{% endblock %}
- Confirmation page (
your_confirmation.html)
{% extends "base.html" %}
{% block content %}
<div class="max-w-md mx-auto mt-12 text-center">
<div class="mb-6">
<svg class="w-16 h-16 mx-auto text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
</div>
<h1 class="text-3xl font-bold mb-4">You're on the list!</h1>
<p class="text-gray-600 mb-4">
We've sent a confirmation to <strong>{{ email }}</strong>.
</p>
<p class="text-gray-600">
We'll notify you when we launch. In the meantime, follow us on social media for updates.
</p>
</div>
{% endblock %}
Email Confirmations
Waitlist confirmations are sent via the send_waitlist_confirmation worker task.
How it works
- Route calls
capture_waitlist_email() - Helper enqueues:
await enqueue("send_waitlist_confirmation", {"email": email, "intent": intent}) - Worker picks up task and calls
handle_send_waitlist_confirmation() - Email sent via Resend (or printed to console in dev)
Email content
Defined in src/padelnomics/worker.py:
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:
RESEND_API_KEY=re_xyz123
How it works
# 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.createerror →audience_idis None, contact skippedContacts.createerror → 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:
- Go to Resend dashboard → Audiences → select the segment (e.g.,
waitlist-suppliers) - Create a broadcast email: "We're live! Here's your early access..."
- Send to the audience
Testing
Unit Tests
Tests are in tests/test_waitlist.py:
uv run pytest tests/test_waitlist.py -v
Test coverage:
- Configuration:
WAITLIST_MODEflag exists and can be toggled - Migration:
waitlisttable 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
-
Enable waitlist mode:
# In .env WAITLIST_MODE=true -
Visit gated routes:
-
Submit emails:
- Valid email → confirmation page
- Invalid email → error message
- Duplicate email → confirmation page (no error)
-
Check database:
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) " -
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
- Start worker:
-
Disable waitlist mode:
# In .env WAITLIST_MODE=false- Restart app
- Visit routes → should see normal signup/checkout flows
Database Schema
waitlist table
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 keyemail— 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.comcan be on both "signup" and "supplier" waitlists - Duplicate submissions for same email+intent are ignored (idempotent)
- Example:
Indexes:
idx_waitlist_email— Fast lookups by email
Queries
Count total signups:
SELECT COUNT(*) FROM waitlist;
Count by intent:
SELECT intent, COUNT(*) as count
FROM waitlist
GROUP BY intent
ORDER BY count DESC;
Recent signups:
SELECT * FROM waitlist
ORDER BY created_at DESC
LIMIT 50;
Duplicates (same email, different intents):
SELECT email, COUNT(*) as count
FROM waitlist
GROUP BY email
HAVING count > 1;
Export for Resend:
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
# .env
WAITLIST_MODE=true
RESEND_API_KEY=re_xyz123
# No audience IDs needed — created automatically on first signup
Config Access
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
-- 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
-- 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
-- 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:
WAITLIST_MODEnot set totruein.env.envchanges not loaded (need to restart app)- Using wrong environment (production vs dev)
Fix:
# 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:
- Database migration not run
- DB connection error
- Email validation rejecting input
Fix:
# 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:
- Worker not running
- Task queue error
- Resend API error
Fix:
# 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:
RESEND_API_KEYnot setAudiences.createAPI error on first signup (check logs)- Resend API rate limit
- Contacts already in audience (silent duplicate)
Fix:
# 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:
- Decorator applied in wrong order
- Missing import
- Decorator syntax error
Fix:
# 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)
@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)
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:
@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:
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:
# 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:
# 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()helpersrc/padelnomics/auth/routes.py— Example: entrepreneur signupsrc/padelnomics/suppliers/routes.py— Example: supplier signup with plan-specific messagingsrc/padelnomics/planner/routes.py— Example: feature gate (export)src/padelnomics/worker.py— Email confirmation task handlertests/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_confirmationtask
Usage:
- Set
WAITLIST_MODE=true - Add
@waitlist_gate("template.html")to routes - Call
await capture_waitlist_email(email, intent)in POST handler - Create waitlist + confirmation templates
- Test, promote, monitor signups
- Export emails, launch, disable waitlist mode