The open-source, self-hostable alternative to Perspective.co, Typeform, and Outgrow.
Build mobile-first, high-converting interactive quiz funnels designed specifically for paid traffic (Facebook, Instagram, TikTok, Google Ads).
| Feature | Perspective.co ($99+/mo) | OpenFunnel (Free & Open Source) |
|---|---|---|
| Mobile-First Quiz Engine | ✅ Yes | ✅ Yes (Swipe transitions, <100ms response) |
| Landing Pages | ✅ Yes | ✅ Yes (5 hero layouts, background image/video, sticky CTA, nav & footer) |
| Page Sections / Blocks | ✅ Yes | ✅ 20 block types (features, pricing, FAQ, stats, comparison, gallery…) |
| Interactive Screen Types | ✅ Yes | ✅ Yes (Landing, Single-choice, Multi-select, Form, Loader, Content, Success) |
| Option Image Cards & Media | ✅ Yes | ✅ Full (Image cards, grid layout & step hero media) |
| Proven Industry Templates | ✅ Yes | ✅ 20 built-in across 7 categories, editable as plain JSON |
| Theming | ✅ Yes | ✅ 8 presets + custom colour, font, radius & button style |
| AI Funnel Copilot | ✅ Yes | ✅ Bring your own key (OpenAI, Claude, Gemini, DeepSeek or any compatible API) |
| Email Alerts & Autoresponders | ✅ Paid Addon | ✅ Built-in (HTML alerts via Resend or an HTTP relay) |
| Email Verification (OTP) | ✅ Yes | ✅ Built-in (6-digit code, verified server-side) |
| Smart Branching & Logic | ✅ Yes | ✅ Yes (Target steps by answer ID) |
| Dynamic Answer Piping | ✅ Yes | ✅ Yes (Inject {{name}}, {{goal}} into any headline) |
| Automatic UTM & Ad Tracking | ✅ Full (utm_source, utm_campaign, gclid, fbclid, ttclid, etc.) |
|
| Zapier, Make & CRM Webhooks | ✅ Yes | ✅ Full (Signed server-side forwarding, endpoint never exposed) |
| Meta Pixel, CAPI, GTM, GA4 | ✅ Yes | ✅ Built-in (Pre-wired event mapping) |
| Monthly Lead Limits | ❌ Tiered Caps | ✅ Unlimited Leads |
| Data Privacy & Self-Hosting | ❌ Vendor Lock-in | ✅ 100% Local / Self-Hostable (JSONL files or Supabase) |
| Price | ~$1,200+/year | $0 / Free Forever |
OpenFunnel is engineered from the ground up for growth marketers, performance media buyers, and agencies running paid ad campaigns:
Every visitor who enters your funnel brings their ad parameters with them. OpenFunnel automatically captures and attaches these details to every lead submission:
utm_source(e.g.facebook,google,tiktok,newsletter)utm_medium(e.g.cpc,paid_social,email)utm_campaign(e.g.summer_promo_2026)utm_content&utm_termgclid(Google Ads Click ID)fbclid(Facebook Ads Click ID)ttclid(TikTok Ads Click ID)ref(Custom Referral Code)
Send your leads anywhere automatically:
- Instant Email Alerts & Autoresponders: Send formatted HTML email alerts to the business owner (
NOTIFY_EMAIL) with full lead answers & UTM parameters, plus personalized welcome emails to leads. Delivery goes through the Resend API or an HTTP relay (SMTP_RELAY_URL) — direct SMTP is not implemented yet. - Webhooks (Zapier, Make.com, GoHighLevel, HubSpot, n8n): Every lead is forwarded server-side, optionally signed with an
X-Webhook-Secretheader. Your endpoint is never exposed to visitors. - CSV Export with Attribution: Export leads in one click with full UTM columns for direct import into Google Sheets or CRMs.
- Supabase Cloud Sync: Sync lead records and analytics directly into your PostgreSQL database.
- Local JSONL Storage: Zero-database setup storing leads locally in
.data/leads.jsonl.
- Meta (Facebook) Pixel & CAPI: Pre-mapped
Lead,ViewContent, andCompleteRegistrationevents. - Google Tag Manager & GA4: Automatically populates
dataLayeron step views and form submissions. - TikTok Pixel: Track mobile conversion events seamlessly.
OpenFunnel is designed to run in two modes depending on your workflow:
- Best for: Designing new funnels, testing question logic, trying templates offline, and local development.
- How it works: You run
bun run devon your computer. Access the console locally atlocalhost:3000/app. - Integrations: Even while running locally on your laptop, submitting a lead will forward data to external webhooks (Zapier/Make) if your computer is connected to the internet.
- Best for: Running live paid ad traffic (Facebook, TikTok, Google Ads), capturing real leads 24/7, and streaming data into your CRM.
- Where to host: Deploy on any cloud platform or VPS:
- PaaS Hosts: Render, Railway, Fly.io, DigitalOcean App Platform
- VPS Hosting: AWS, Hetzner, Linode, Vultr running Ubuntu + Bun
- How it works: Point your custom domain (e.g.,
https://quiz.yourdomain.com) to your server. - Live Tool Sync: Operates 24/7 without needing your personal computer open. Automatically pushes leads to Zapier, GoHighLevel, HubSpot, and Supabase.
⚠️ Required before going live: setADMIN_TOKEN(andTRUST_PROXY=1if your host terminates TLS in front of you) in your server environment, or the console APIs stay locked to localhost and you will not be able to reach your lead inbox remotely. See Security & Privacy.
⚠️ Run exactly one instance, or plan for shared state first.Rate limits, OTP challenges, the verified-email record and the hourly mail cap are all plain
Maps in the server process. They are not shared between replicas and they reset on restart. That is fine — good, even — for one instance, which is what most self-hosters need. It breaks in two specific ways the moment you scale out, and neither one throws:
- Email verification fails intermittently. An OTP issued by instance A is not in instance B's memory, so a visitor who gets load-balanced between requests is told their correct code is invalid.
- Every ceiling silently multiplies by your replica count. Three instances means three times the configured
MAIL_MAX_PER_HOURand three independent sets of per-IP rate-limit buckets. Your limits are still "on"; they are just three times looser than the number you set.Before you add a second instance you need an edge rate limit (Cloudflare, your load balancer) and a shared OTP store (Redis, or a Supabase table). The server prints this same warning at boot when
NODE_ENV=production.Autoscaling counts as scaling out. A PaaS that quietly adds a replica under load puts you here without a deploy — pin the instance count to 1 on Render/Railway/Fly unless you have done the above.
┌───────────────┐ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐
│ 1. Launch App │──►│ 2. Pick │──►│ 3. Build the │──►│ 4. Edit the │
│ │ │ Template │ │ Landing Page│ │ Questions │
└───────────────┘ └────────────────┘ └────────────────┘ └───────┬────────┘
│
┌────────────────┐ ┌────────────────┐ ┌────────────────┐ │
│ 7. Collect │◄──│ 6. Alerts & │◄──│ 5. Theme It │◄─┘
│ Leads │ │ Webhooks │ │ │
└────────────────┘ └────────────────┘ └────────────────┘
Start OpenFunnel on your server or local environment and open your admin console at /app.
20 pre-built funnels, filterable by category in /templates. Each is modelled on
a paid-traffic pattern rather than a feature demo — a landing page that sells the
click, two to four qualifying questions, a "warming" loader, then the form:
- 📈 Lead Gen (6): agency qualifier, lead magnet, insurance quote, mortgage pre-approval, social recruiting, newsletter.
- 📅 Booking (1): high-ticket application call, with branches that disqualify on budget and revenue so your calendar stays clean.
- 🛒 E-commerce (2): product-finder quiz, launch waitlist with countdown.
- 💻 SaaS (2): trial & demo with an enterprise branch, free ROI audit calculator.
- 🏡 Local Services (6): home valuation, solar savings, med spa, home improvement, car trade-in, gym trial.
- 🎯 Coaching (2): 1:1 application, 6-week body challenge.
- 🎟️ Webinar (1): live masterclass registration.
A cold ad click is not ready for question 1. The landing step is a full
marketing page rendered as the first step of the funnel, so its CTAs advance
straight into the quiz — one document, no second page builder:
- 5 hero layouts: centred, left, split (media beside copy), full-bleed, minimal.
- Backgrounds: image or muted looping video behind an adjustable scrim, or a flat colour / CSS gradient.
- Chrome: top nav with logo and links, sticky CTA bar that follows the scroll, footer for your imprint and privacy links.
- Proof: star rating, face pile, eyebrow badge, scroll hint.
- 20 section blocks below the hero: heading, feature cards, how-it-works steps, stat row, FAQ accordion, pricing plans, image gallery, us-vs-them comparison, pull quote, trust badges, countdown, calculator, checklist, mid-page CTA, divider, spacer, image, video, paragraph, review cards.
Every section is an ordinary content block, so the same 20 types are available on
any step — a choice step can carry a testimonial above its options. A landing
step hides the progress bar by default (it is a page, not question 1 of 6), and
width: "wide" breaks it out of the 9:16 phone frame on desktop.
Use the live mobile editor (/builder) featuring a clean 4-Tab Inspector (Content, Design, Blocks, Logic) to customize:
- Conversion Step Archetype Library: 1-click add 8 pre-built conversion step templates (Single-Choice Quiz with auto-advance, Multi-Select, Lead Capture Form with Anti-Spam OTP, VSL Video, ROI Calculator, Testimonials & Reviews, Pricing Comparison, Landing Page).
- Visual Drag & Drop Editor: Drag step rows in the spine, choice options, or content blocks with glowing drop target lines (
drop-before/drop-after) and explicit⋮⋮ Draggrab handles. - Palette Drag-to-Add: Drag content block chips directly from the palette onto any step.
- Dual Reordering & Duplication: Every list supports both drag-and-drop AND 1-click Up/Down arrow buttons plus 1-click Step & Option Duplication.
- Auto-Advance Toggle: 1-click toggle on choice steps for instant single-choice quiz progression.
- Dynamic Variable Piping: Insert previous answers with 1-click token chips (
{{name}},{{email}},{{answers.budget}},{{score}}). - Question Steps: Single-choice options with icons (
📈,🧭,💡) or Image Cards with photo grid layouts, plus conversion badges ("🔥 Popular", "⭐ Best Value", "⚡ Recommended"). - Form Capture & Anti-Spam OTP: Collect contact info with built-in 6-digit email OTP verification.
- Branching & Disqualification: Direct option branching targets to route qualified prospects to calendar booking and disqualified leads to exit pages.
- Live Device Preview: Phone / tablet / desktop switching with instant iframe preview updates.
Cmd/Ctrl+Kopens the command palette,Cmd/Ctrl+Ssaves,1–6switch views.
The Theme modal gives you 8 presets (midnight-glass, neo-brutalist,
warm-editorial, saas-gradient, clean-light, emerald-glow,
violet-pulse, sunset-coral) plus a custom accent colour, font, corner radius,
light/dark mode and three button styles (flat, glow, pressable-3d).
Settings holds the funnel-level behaviour flags — back button, swipe navigation, resume-on-return and the GDPR consent bar. These are saved onto the funnel document, not your browser, which is what lets them reach a real visitor. Hide OpenFunnel Branding removes the "Powered by OpenFunnel" footer; note that AGPL-3.0 still requires you to offer your source to users of a modified public deployment, so hiding the footer is a styling choice, not a licence waiver.
Preset themes hotlink Google Fonts. Every built-in preset names a non-system font family, so a funnel using one asks
fonts.googleapis.comfor it — handing Google the visitor's IP. The default theme requests nothing, and the consent bar gates the request when enabled. See Third-party data sharing.
In Settings, set your admin notification email (NOTIFY_EMAIL) and a Resend API key to receive instant HTML lead alerts and trigger personalized autoresponders. In Pixels & Tracking, paste your Webhook URL (Zapier/Make/GoHighLevel), an optional webhook secret, and your Meta/GTM Pixel IDs.
Webhook delivery is server-side, so your endpoint and secret are never exposed in the funnel page. If you set a secret, each delivery carries it as an X-Webhook-Secret header for your automation to check.
Optionally enable email verification on a form step ("verifyEmail": true). The visitor receives a 6-digit code and the funnel only records email_verified: true once the server confirms it — a fake address cannot claim to be verified.
Share your live funnel link (/f/your-funnel-slug). Review submitted leads in your Lead Inbox (/leads) or download them via Export CSV.
-
Clone the repository:
git clone https://github.com/luispdoesai/openFunnel.git cd openFunnel -
Install dependencies with Bun:
bun install
-
Start the local server:
bun run dev
-
Explore local routes (default port
3000):- Unified App Console:
/app - Visual Builder:
/builder - Template Library:
/templates - Lead Inbox:
/leads - Analytics Dashboard:
/analytics - Settings:
/settings - Live Mobile Demo:
/f/lead-gen - Landing Page Demo:
/f/agency-landing
- Unified App Console:
-
Run test suite:
bun test # engine + runtime, including security regression tests bun run typecheck # JSDoc types across the engine
Running locally needs no configuration: the console APIs accept callers on
localhost without a token. Setting ADMIN_TOKEN becomes necessary the moment
the server is reachable from anywhere else.
To forward leads to Zapier, Make.com, GoHighLevel, HubSpot, or a custom CRM, set your webhook URL in your environment:
# In your .env.local file
WEBHOOK_URL="https://hooks.zapier.com/hooks/catch/123456/abcdef"Or configure it per-funnel directly in the Visual Builder under Pixels & Tracking → Webhook URL.
Set WEBHOOK_SECRET (or the per-funnel secret) and every delivery carries it as an X-Webhook-Secret header — compare it on your side before trusting a payload. Forwarding runs on the server, so neither the URL nor the secret ever appears in the funnel page, and OpenFunnel refuses to POST to loopback, private-network or cloud-metadata addresses.
Copy .env.example to .env.local:
# Runtime Configuration
PORT=3000
FUNNELS_DIR=examples/
DATA_DIR=.data/
# Admin access — REQUIRED once this server is reachable off-host.
# Guards the lead inbox, the funnel editor and your mail credentials.
# Generate with: openssl rand -hex 32
# Leave blank and those routes accept localhost only (fine for local dev).
ADMIN_TOKEN=
# Absolute ceiling on outbound mail per hour, across all callers — covers both
# the OTP challenge and the lead autoresponder. Both mail an address taken from
# a public request body, and their per-IP limits key off x-forwarded-for, which
# the caller sets. This cap is the one a caller cannot rotate past. Default 500.
MAIL_MAX_PER_HOUR=
# Set to 1 ONLY if this server really sits behind a proxy/CDN that rewrites the
# client address (Render, Railway, Fly, nginx, Cloudflare). x-forwarded-for is a
# request header anyone can send, so it is ignored unless you opt in — otherwise
# every per-IP limit would be bypassed by rotating a string. Deploy behind a
# proxy without this and all traffic shares one rate-limit bucket; the first
# forwarded request logs a warning telling you so.
TRUST_PROXY=
# Extra hostnames allowed to use loopback trust when ADMIN_TOKEN is unset. Only
# needed if you reach the console by a name other than localhost without a token.
# Anything unlisted is refused — this is what stops a DNS-rebinding page from
# driving the console through your own browser.
ALLOWED_HOSTS=
# Email Notifications & Autoresponders
NOTIFY_EMAIL=owner@yourdomain.com
EMAIL_PROVIDER=resend
RESEND_API_KEY=re_123456789...
RESEND_FROM="OpenFunnel Leads <leads@yourdomain.com>"
# Optional HTTP-to-SMTP relay. Every message is POSTed here as JSON.
# Env-only by design: a relay URL settable through the API would let an
# attacker redirect every lead notification to themselves.
SMTP_RELAY_URL=
# Stored for a future direct-SMTP transport. Setting only these sends nothing.
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASS=
SMTP_FROM="OpenFunnel <noreply@yourdomain.com>"
# Global Webhook Forwarding (Zapier, Make, GoHighLevel, CRMs)
WEBHOOK_URL=https://hooks.zapier.com/hooks/catch/...
# Legacy alias for WEBHOOK_URL, still read as a fallback.
ZAPIER_WEBHOOK_URL=
# Sent as the X-Webhook-Secret header so your automation can verify the sender.
WEBHOOK_SECRET=
# Supabase Sync (Optional)
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
# Meta Conversions API — server-side conversion forwarding (Optional).
# Both are server-only. Never expose the token to the browser: it can write
# conversions into your ad account. Sends visitor IP and user-agent to Meta,
# so see "Third-party data sharing" below before enabling it.
META_PIXEL_ID=
META_CAPI_TOKEN=
# AI copilot (Optional) — fallback key for the admin-only /api/ai/* routes.
# The console can supply its own key per request, in which case this is unused.
OPENAI_API_KEY=
# "production" enables the 60s funnel cache and long-lived asset caching.
# Anything else (including unset) is treated as development, where the funnel
# cache is disabled so editing a JSON and reloading just works.
NODE_ENV=
# Execute <script> pasted into a funnel's Custom head/body fields. OFF by
# default: funnel pages share an origin with the console, so a script here can
# read your admin token. Opting in allows only the exact scripts you pasted
# (by SHA-256) — never a blanket 'unsafe-inline'. See "Custom Code Injection".
ALLOW_CUSTOM_SCRIPTS=
# Extra origins a pasted loader pulls further scripts from (space/comma list).
CUSTOM_SCRIPT_ORIGINS=The AI copilot falls back silently. With no usable key,
/api/ai/generatereturns a hardcoded built-in funnel rather than an error — a200does not mean a model ran. If the generated funnel looks generic, check the key before assuming the model ignored your prompt. The built-in generator is also the default (Built-in generator — no key needed), so this is the expected path until you configure a provider.
Browser pixels are not configured here. Meta, GA4/GTM and TikTok pixel ids live in each funnel document under
integrations— set them per funnel in the console's Pixels modal. There are noNEXT_PUBLIC_*pixel variables; the engine reads pixel ids from the funnel JSON, never from the environment.
Settings → AI & Global Injection picks the provider, model and key. The routes are admin-only, and the key travels from the console to your own server — never to the funnel page.
| Provider | Key format | Default model |
|---|---|---|
| Built-in generator (default) | none | — returns a fixed 5-step funnel |
| OpenAI | sk-… |
gpt-4o |
| Anthropic Claude | sk-ant-… |
claude-3-7-sonnet-20250219 |
| Google Gemini | Google AI Studio key | gemini-2.0-flash |
| DeepSeek | DeepSeek key | deepseek-chat |
| Custom / BYO | any | falls through to the OpenAI-compatible chat-completions shape |
The model field is free text with suggestions, so any identifier your provider
accepts works — routing is decided by the provider you pick, with the model
prefix (claude-, gemini-, deepseek-) and key prefix (sk-ant-) as
fallbacks. Two admin-only routes use it: /api/ai/generate builds a whole funnel
from a prompt, and /api/ai/improve-copy powers Suggest headlines in the
step inspector.
The console stores your API key in that browser's
localStorage. It is a per-browser convenience credential, not shared server config — on a shared machine, clear it when you are done.
Pixels & Tracking has three fields written onto the funnel document
(customCss, customHead, customBody) and injected into the published funnel
page. What actually survives is governed by the funnel page's strict CSP:
| Field | Injected into | Default |
|---|---|---|
| Custom CSS | a <style> in <head> |
✅ Applied |
Custom <head> HTML |
end of <head> |
✅ Markup (meta, link) — <script> needs the opt-in below |
Custom <body> HTML |
end of <body> |
✅ Markup — <script> needs the opt-in below |
Scripts are refused until you opt in, and that default is load-bearing rather
than an oversight: a funnel page is served from the same origin as the
console, and your admin token lives in that origin's localStorage. A script
running on a funnel page can therefore read it and drain /api/admin/* — your
whole lead database. Funnel documents also get imported from templates and bug
reports (see Treat a funnel document as code),
so executing whatever a document carries would turn "I imported a funnel JSON"
into console takeover.
To enable it:
ALLOW_CUSTOM_SCRIPTS=1
# Optional: origins a pasted loader pulls *further* scripts from, which cannot
# be read off the snippet itself.
CUSTOM_SCRIPT_ORIGINS="https://www.clarity.ms https://static.hotjar.com"Opting in does not drop the policy to 'unsafe-inline'. Each inline script
is allowed by the SHA-256 of its exact bytes, and each external one by its own
origin (granted on both script-src and connect-src, so the script can beacon
home rather than loading and silently reporting nothing). Only what you pasted
runs — an injected step.consent, or an XSS in a future renderer, still cannot
execute, because their content was never hashed into the policy.
With the flag off, a funnel carrying script logs a warning server-side at request time naming the funnel and the number of tags refused. It used to fail completely silently: a CSP violation in the visitor's console, nothing on the server, and a field that saved as if it had worked.
For Meta / GA4 / GTM / TikTok, prefer the first-class Pixels fields — they
add their own origins to the policy and need no opt-in. Note that anything GTM
loads at runtime is subject to the same policy, so a container pulling arbitrary
vendor tags will need those origins in CUSTOM_SCRIPT_ORIGINS.
The core engine (packages/engine) has zero dependencies and can be mounted into any HTML page, React, Vue, Next.js, or Astro project:
<div id="funnel-container"></div>
<script type="module">
import { createFunnel } from "./packages/engine/src/index.js";
const funnelConfig = {
id: "lead-gen",
slug: "lead-gen",
theme: { primary: "#4f46e5", mode: "light" },
steps: [
{
id: "home",
type: "landing",
layout: "centered",
height: "tall",
eyebrow: "Free 60-second quiz",
headline: "We find the leaks in your ad account",
subtext: "Answer three questions and get a personalised breakdown.",
background: { gradient: "linear-gradient(160deg,#4f46e5,#020617)", ink: "light" },
cta: { label: "Start my free audit", note: "No credit card" },
stickyCta: true,
// `blocks` on a landing step is the page body below the hero.
blocks: [
{ type: "stats", items: [{ value: "4.8×", label: "Median ROAS" }] },
{ type: "faq", items: [{ q: "Is it free?", a: "Yes." }] }
]
},
{
id: "q1",
type: "choice",
headline: "What is your main business goal?",
options: [
{ id: "grow", label: "Increase Sales", icon: "📈" },
{ id: "leads", label: "Get More Leads", icon: "🎯" }
]
}
]
};
createFunnel(document.getElementById("funnel-container"), funnelConfig, {
leadEndpoint: "/api/lead",
onEvent: (event) => console.log("Funnel Event:", event),
});
</script>Your console reads real people's names, emails and phone numbers. Every console API — the lead inbox, the funnel editor, and your mail credentials — is protected by a single shared secret:
# generate one
openssl rand -hex 32
# then set it in your server environment
ADMIN_TOKEN=<the value you generated>Paste the same value into the console under Settings → Admin API token.
If you leave it blank, the server still refuses those routes to everyone
except callers on localhost — so bun run dev works with no setup, and a
public deployment fails closed instead of quietly exposing your leads. A request
arriving through a proxy is never treated as local, so putting nginx or a CDN in
front does not accidentally grant access.
This server speaks plain HTTP — it has no TLS of its own, by design, because
every recommended host already terminates TLS for you. That means you must not
expose it directly on a public IP over http://. Two things travel in the
clear if you do:
- your
ADMIN_TOKEN, sent as anAuthorization: Bearerheader on every console request — anyone able to observe the traffic gets your whole lead inbox; - the leads themselves: names, emails and phone numbers, in request bodies.
On Render, Railway, Fly.io or DigitalOcean App Platform this is handled for you —
just set TRUST_PROXY=1 so per-IP limits see the real client address. On a bare
VPS, put nginx, Caddy or Cloudflare in front with a certificate (Caddy issues one
automatically) and bind this process to 127.0.0.1 so it is only reachable
through the proxy.
- Local data ownership: leads and events stay in
.data/leads.jsonlunless you route them outward. - Credentials are never echoed back: the settings API reports whether a Resend or SMTP secret is set, never its value.
- Outbound destinations are operator-owned: webhook targets come from your environment or your funnel document, never from a visitor's request, and loopback / private / cloud-metadata addresses are refused.
- Signed webhooks: set a webhook secret and every delivery carries an
X-Webhook-Secretheader your automation can check. - Email verification that actually verifies: six-digit codes from a CSPRNG, five attempts, ten-minute expiry, never returned to the browser — and the server re-derives
email_verifiedrather than believing the client. - Abuse limits: the ingest, OTP and mail endpoints are rate-limited per address and per caller, and every outbound mail path — the OTP challenge, the autoresponder and the lead notification — additionally passes a global hourly ceiling (
MAIL_MAX_PER_HOUR), each in its own bucket — the per-caller key comes fromx-forwarded-for, which a caller can rotate, so it cannot be the only bound. - The console's APIs refuse cross-site browser requests: privileged routes reject a cross-site
Origin/Sec-Fetch-Sitebefore authenticating, and CORS is scoped to the public ingest paths only. Without this, a page you merely visit could drive the console on a default local install, where the admin gate trusts loopback. - The console cannot be framed:
X-Frame-Options: DENYplusframe-ancestors 'none'on every operator-facing page, so a lure page cannot overlay an invisible console and borrow your session. Funnel pages deliberately stay framable — being embeddable is the point. - Escaped output: lead data is escaped into notification emails, the lead inbox, and the funnel HTML shell.
- Request bodies are capped at 64KB at the transport layer, so a public
endpoint cannot be made to buffer a large body — with the cap left to the JSON
parser alone, a chunked request sends no
Content-Lengthand the whole body is read into memory before anything can reject it. - Path-traversal validation (
SLUG_REplus anisInside()containment check that requires a path separator, so a sibling directory sharing the root's name cannot be reached) on every route that touches a file. - Outbound destinations are filtered: webhook targets are refused for loopback, private, link-local, CGNAT and cloud-metadata addresses, including IPv4-mapped IPv6 and the decimal/hex IP spellings.
- No third-party sharing until you configure it: pixels fire only for the ids
you put in a funnel's
integrations, and the Meta Conversions API forward is inert unless you setMETA_PIXEL_IDandMETA_CAPI_TOKEN. A funnel on the default theme makes no external request at all; one on a built-in preset theme fetches that preset's webfont from Google unless the consent bar gates it. - Consent is enforced, not decorative: turn on the consent bar for a funnel
and pixels stay uninstalled, the webfont stays unrequested, and the CAPI forward
is skipped until the visitor accepts. The server reads
consent.enabledfrom your funnel document, so stripping the field out of a request does not turn the gate off. See Third-party data sharing.
A funnel JSON is operator-authored input that the engine trusts. One field is
rendered as raw HTML on purpose — a form step's consent, so you can put a link
to your privacy policy in it — and nothing sanitises it.
That is safe for funnels you wrote, and it is why the write path is locked down (admin gate + cross-site refusal). It has two consequences worth knowing:
- Don't import a funnel JSON you didn't write — from a gist, a template pack, or a bug report — without reading it first. On a page served by this runtime the strict CSP stops it executing script, so the realistic damage is injected markup (a phishing link, a third-party tracking pixel) rather than code execution.
- If you embed the engine in your own page (the section above), that CSP is
yours to set, and without one an untrusted
consentfield is straightforward XSS on your origin. Serve embedded funnels with ascript-srcthat does not include'unsafe-inline', or only embed documents you control.
Anyone who can reach the console can write a funnel document, which is the real
reason ADMIN_TOKEN matters — see above.
Three things send visitor data off your server. All three are opt-in, and all three are worth understanding before you enable them:
| Feature | What leaves | Turned on by |
|---|---|---|
| Browser pixels (Meta, GA4/GTM, TikTok) | Whatever the platform's script collects in the visitor's browser, including cookies it sets | Pixel ids in a funnel's integrations, via the console's Pixels modal |
| Meta Conversions API | Visitor IP address and user-agent, server-side, per lead and per event | META_PIXEL_ID + META_CAPI_TOKEN in the environment |
| Google Fonts webfont | Visitor IP address, user-agent and Referer (the funnel URL) sent to fonts.googleapis.com by the browser |
A theme.font naming a non-system family — which every built-in preset theme does. The default theme requests nothing |
An IP address is personal data under GDPR, so the CAPI forward and the webfont request are both third-party transfers you need a lawful basis to make — neither is covered by "we set no cookies."
To gate all three on consent, enable the consent bar on the funnel (Settings →
GDPR & Privacy Consent Bar, saved onto the funnel document as consent.enabled).
Then:
- Gated — browser pixels are not installed at all until the visitor accepts, the webfont is not requested until then either (colours and layout apply immediately; the font swaps in on accept), and the server skips the CAPI forward for any record that is not an explicit grant.
- Not gated — lead capture (
/api/lead) and your own drop-off analytics (/api/events). The visitor filled the form in and pressed submit; dropping that would be a broken funnel, not a private one, and those records stay on your server.
The decision is stored first-party in localStorage and is not re-prompted. A
funnel with no consent bar behaves exactly as it did before this existed, so
enabling it is the only thing that changes behaviour.
Scope note. Rate limits and OTP state live in memory, so they are per-process — run more than one instance and you want an edge rate limit and a shared store. The console has no multi-user accounts or audit log; the admin token is all-or-nothing access. Direct SMTP is not implemented — use Resend or an HTTP relay via
SMTP_RELAY_URL.
The console will happily save these. They are honest TODOs, not working features, and are listed here so you don't ship a campaign depending on one:
- Google Ads and LinkedIn pixel fields (Pixels & Tracking). Saved onto the funnel document, but the engine's pixel layer only implements Meta, GA4, GTM and TikTok — nothing fires for either. (A Pinterest handler exists in the console's code with no input field to drive it, so that one is inert twice over.)
- Custom
<head>/<body>scripts — injected, but not executed until you setALLOW_CUSTOM_SCRIPTS=1. This one is a deliberate default rather than a TODO; see Custom Code Injection above for why. - Workspace currency, language, notification email and "global code"
(Settings). Stored in that browser's
localStorageonly. They are per-browser values, so they can never reach a visitor — anything visitor-facing has to live on the funnel document, the way the consent bar and the branding toggle now do. - The
selectandfileform field types. The engine rendersselect, but the console has no editor for its options, so it is deliberately absent from the field-type dropdown rather than offered as an empty dropdown. Nothing in the ingest path stores an uploaded file.
This project is licensed under the GNU Affero General Public License v3.0 (AGPL-3.0).
AGPL v3 ensures OpenFunnel remains free and open-source forever. Anyone hosting a modified version as a public service must release their source code back to the open-source community.