A self-select lead router, built the WordPress way.
This documents a working proof-of-concept of Braven's Milestone 1 deliverable — a guided self-selection tool that routes cities, counties, chambers, and foundations to the right next step, captures the lead cleanly, and pushes it to the CRM, an email workflow, and GA4. It's a real WordPress plugin on your exact stack: WordPress + Elementor + custom PHP (CPTs / ACF) + GTM/GA4 + webhooks. No page-builder bloat.
Overview
The brief was specific: not a strategist, not an AI-tool builder — a WordPress engineer who ships a working, converting, fast lead-routing tool and can prove it. This build is that tool, plus the tracking layer and the CPT-backed video repository, delivered as one cohesive, dependency-light plugin.
What the visitor experiences
Each answer feeds a transparent scoring engine. The visitor lands on a tailored view — their organization type and goal reflected back, the right call-to-action, and only the form fields that step actually needs. On submit, the lead is stored, sent to the CRM with retries, emailed to the team, acknowledged to the prospect, and recorded in GA4.
Milestone 1 — the router
The self-select flow, the scoring engine, and clean CRM + email capture. The primary deliverable.
Milestone 2 — tracking
GTM dataLayer events at every step + a redundant server-side GA4 conversion. Full-funnel measurability.
Milestone 4 — video library
A categorized, searchable training repository on CPTs + taxonomies. No directory plugin, no bloat.
Phase 2 — AI hooks
Clean action hooks, REST endpoints, and data structures so your AI team layers personalization in without a rebuild.
Architecture: a testable core, a thin WordPress skin
The one decision that makes everything else clean: the routing brain has no
WordPress dependency. It's plain PHP that takes a selection and returns a decision. WordPress
— CPTs, REST, Elementor, the admin — is a thin adapter layer wrapped around it. That's why the
whole routing contract is unit-tested with php tests/test-routing.php (26 assertions,
zero WordPress boot) and why the logic is trivial to reason about.
class-blr-routing-engine.php — score answers, pick a tier, resolve the outcome. The conditional logic.class-blr-lead-validator.php — sanitise + validate a submission (honeypot, consent, required fields).class-blr-webhook-dispatcher.php — build the CRM payload, sign it, retry, log. (One optional wp_remote_post seam, falls back to cURL.)class-blr-ga4.php — build + send the GA4 Measurement Protocol event.class-blr-cpt.php · -fields · -rest · -shortcode · -elementor-widget · -admin · -lead-repository — bind the core to WordPress.class-blr-core.php — the bootstrapper that wires it all on plugins_loaded.data/routes.php — a declarative matrix — and nothing
in the engine changes. When an engineer wants to verify a change, the tests run in a second with no
database. New buyer segments, new scoring, new outcomes: config edits, not rebuilds.How the custom code fits inside a standard WordPress install
Everything below is stock WordPress — no forks, no core hacks, no exotic
dependencies. It's a single plugin folder in wp-content/plugins/ plus a child theme.
Drop it into any WordPress 6.4+ site and activate.
The plugin folder
wp-content/plugins/braven-lead-router/ braven-lead-router.php # plugin header, constants, activation hooks includes/ class-blr-routing-engine.php # PURE — the decision brain class-blr-lead-validator.php # PURE — sanitise + validate class-blr-webhook-dispatcher.php # PURE* — CRM webhook, retries, HMAC class-blr-ga4.php # PURE — server-side GA4 Measurement Protocol class-blr-email-workflow.php # wp_mail + blr_lead_captured hook class-blr-lead-repository.php # persist a lead as the blr_lead CPT class-blr-cpt.php # register CPTs + taxonomies + custom table class-blr-fields.php # register_post_meta + ACF integration class-blr-rest.php # register_rest_route — /braven/v1/* class-blr-shortcode.php # [braven_lead_router], [braven_video_library] class-blr-elementor-widget.php # the same tool as an Elementor block class-blr-admin.php # columns, meta box, Routing Console, Settings class-blr-core.php # bootstrapper helpers.php data/routes.php # the routing matrix (editable config) data/videos.php # seed catalog for the library templates/ # server-rendered markup assets/css · assets/js # ~6KB CSS, ~7KB JS, no jQuery acf-json/ # ACF Pro field-group exports readme.txt · uninstall.php
The mapping from "custom features" to "standard WordPress primitives" is one-to-one:
| Feature | WordPress primitive | Where |
|---|---|---|
| Captured leads | Custom Post Type blr_lead + post meta | class-blr-cpt / -lead-repository |
| Training videos | CPT blr_video + taxonomies blr_track, blr_proficiency | class-blr-cpt |
| CRM delivery log | Custom table via dbDelta() | class-blr-cpt |
| Editable lead fields | ACF Pro field group (or native meta box fallback) | acf-json / class-blr-admin |
| Wizard & capture API | register_rest_route() | class-blr-rest |
| Placement on a page | Shortcode and Elementor widget | class-blr-shortcode / -elementor-widget |
| Operator console | Admin submenu + custom list columns | class-blr-admin |
| Tracking | wp_head dataLayer + wp_remote_post to GA4 | class-blr-core / -ga4 |
| Extensibility | Action hook blr_lead_captured | class-blr-email-workflow |
Custom Post Types & taxonomies
Leads and videos are Custom Post Types — the idiomatic WordPress way to get an admin UI, search, capabilities, REST, and (crucially for your stack) ACF Pro compatibility for free. Videos add two hierarchical taxonomies so terms stay indexed and filterable.
register_post_type( 'blr_lead', [ 'public' => false, // leads are private — never a public URL 'show_ui' => true, // but fully manageable in wp-admin 'show_in_rest' => false, // PII never exposed over public REST 'supports' => [ 'title' ], ] ); register_taxonomy( 'blr_track', 'blr_video', [ 'hierarchical' => true, 'show_admin_column' => true ] ); register_taxonomy( 'blr_proficiency', 'blr_video', [ 'hierarchical' => true, 'show_admin_column' => true ] );
A custom table — where a CPT is the wrong tool
Leads are a CPT; the webhook delivery log is not. It's high-volume, append-only audit
data — one row per CRM send attempt — so it lives in a real table created with dbDelta()
on activation. Right tool per job; this is the "custom database structures" the Build Test asks about.
CREATE TABLE {$wpdb->prefix}blr_deliveries ( id BIGINT UNSIGNED AUTO_INCREMENT, lead_id BIGINT UNSIGNED, idem_key VARCHAR(64), attempt SMALLINT, http_status SMALLINT, result VARCHAR(20), message VARCHAR(255), created_at DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY lead_id (lead_id), KEY idem_key (idem_key) );
ACF Pro — a first-class citizen, never a hard dependency
The plugin works with or without ACF. Without it, meta is registered and typed via
register_post_meta() and the admin renders a native read-only panel. With ACF Pro
active — your stack — the shipped field groups in acf-json/ take over the editing UI,
bound to the same _blr_* meta keys the engine already reads. Zero migration: ACF
simply upgrades the interface.
// class-blr-fields.php — point ACF's loader at our shipped field groups add_filter( 'acf/settings/load_json', function( $paths ){ $paths[] = BLR_DIR . 'acf-json'; return $paths; } );
Two groups ship: group_blr_lead.json (name, org, email, phone, buyer type, track,
intent tier/score, outcome, CRM status) and group_blr_video.json (video URL, duration).
REST API
The wizard talks to three endpoints under the braven/v1 namespace. Routing is
separated from capture so the tailored view can render (and fire the routed dataLayer event)
before asking for a single piece of PII — lower friction, and it lets you A/B the routed
copy without resubmitting contact details.
| Endpoint | Method | Purpose | Auth |
|---|---|---|---|
/braven/v1/config | GET | buyer types, tracks, qualifiers | public (no PII) |
/braven/v1/route | POST | selection → routing decision | public (no PII) |
/braven/v1/lead | POST | capture: CPT + CRM + email + GA4 | REST nonce + honeypot |
register_rest_route( 'braven/v1', '/lead', [ 'methods' => 'POST', 'callback' => [ 'BLR_REST', 'post_lead' ], 'permission_callback' => '__return_true', // gated inside by nonce + honeypot ] );
Shortcode and Elementor — one renderer
The job spec's exact principle: keep the deep logic in custom PHP; keep Elementor clean and
usable for marketers. So there is one renderer, exposed two ways. Marketers drag the
“Braven Lead Router” widget onto any Elementor page (with heading/intro controls); developers
or a classic page use [braven_lead_router]. The Elementor widget carries no logic —
it calls the shortcode renderer.
// class-blr-elementor-widget.php — the widget is a thin wrapper protected function render() { $s = $this->get_settings_for_display(); echo BLR_Shortcode::render_router( [ 'heading' => $s['heading'], 'intro' => $s['intro'] ] ); }
Assets enqueue only on pages that actually contain the tool — nothing loads site-wide.
Admin surfaces
- Partner Leads — the
blr_leadlist with custom columns: buyer type, track, intent tier + score, and a colour-coded CRM status badge. Triage from the list without opening a record. - Routing Console — a dashboard page: leads by intent tier + the live CRM webhook delivery log.
- Settings — CRM webhook URL & secret, GA4 Measurement ID + API secret, GTM container ID, booking URL, lead-magnet URL, notification email. No code edits to reconfigure.
Theme & GTM
Presentation is a hello-elementor child theme — your live theme — that loads
Braven's fonts (Nunito Sans + Playfair Display) and palette (gold #c7945b, warm ink,
cream). GTM is printed into wp_head only when a container ID is set, seeding the same
dataLayer the wizard pushes to. On your site you'd set the ID to your existing
GTM-535B5NR container and the events flow straight in.
The engine
Routing logic — transparent and auditable
The screening question is: walk us through the conditional logic. Here it is in full. Every qualifier answer carries a score. The sum, plus a small weight for higher-value segments, places the lead in a tier. A short, ordered rule list turns (buyer type, tier, funding posture) into an outcome — first match wins, with a safe default so no lead is ever dropped.
1 · Score the answers
| Qualifier | Answers → points |
|---|---|
| Funding | Funded 40 · Budgeted 25 · Seeking 10 · Exploring 0 |
| Timeline | This quarter 30 · 6 months 20 · This year 10 · Unsure 0 |
| Role | Approves budget 20 · Recommends 12 · Researching 4 |
| Scale | 500+ 20 · 100–500 14 · 25–100 8 · <25 3 |
| Segment weight | City/County/Foundation +10 · Chamber/Corporate +5 |
2 · Map score → tier
Tier A · High intent ≥ 70 Tier B · Qualified 35–69 Tier C · Nurture < 35
3 · Resolve the outcome (first match wins)
| # | Condition | Outcome | Next step |
|---|---|---|---|
| 1 | Foundation + funding = seeking/exploring | Funding partnership | Funding intake + playbook |
| 2 | Tier A | Book a call | Booking link + short confirm form |
| 3 | Tier B | Request a proposal | Fuller intake (adds goals) |
| 4 | Tier C (default) | Nurture | Program overview + newsletter |
The decision also attaches tailored copy — the buyer type's value proposition and the track label — so the routed view is never a generic form. Example decisions from the test suite: a funded, decision-making city training 500+ businesses (score 120) → Book a call; a budgeted county recommender (score 60) → Request a proposal; an exploring chamber (score 12) → Nurture; a foundation seeking funding → Funding partnership (the special rule beats the tier).
// data/routes.php — outcomes are config, not code 'rules' => [ [ 'when' => [ 'buyer_type_in' => ['foundation'], 'funding_in' => ['seeking','exploring'] ], 'outcome' => 'funding_partnership' ], [ 'when' => [ 'tier' => 'A' ], 'outcome' => 'book_call' ], [ 'when' => [ 'tier' => 'B' ], 'outcome' => 'request_proposal' ], [ 'when' => [ 'tier' => 'C' ], 'outcome' => 'nurture' ], ],
The capture funnel
One POST to /braven/v1/lead fires the whole funnel, in order:
Each stage degrades safely: a failed email never fails capture; an unconfigured CRM is recorded, not thrown. The lead is captured no matter what downstream does.
CRM handoff — Smrts-shaped, idempotent, retried
The lead POSTs to a configurable webhook — your Smrts CRM inbound hook, or a Zapier/Make catch-hook. The payload is CRM-neutral and signed; delivery is idempotent and retried; every attempt is logged to the custom table and surfaced in the Routing Console.
Payload → Smrts field mapping
| Payload path | Meaning | Maps to (Smrts / CRM) |
|---|---|---|
contact.email, .name, .phone, .organization, .title | Contact identity | Contact/Lead record fields |
segmentation.buyer_type | city / county / chamber / foundation / corporate | Lead source segment / list |
segmentation.track | Program interest | Interest / campaign tag |
segmentation.intent_tier + intent_score | A / B / C + numeric | Lead score / priority |
segmentation.outcome | Routed next step | Pipeline stage / workflow trigger |
attribution | UTM + gclid | Source attribution fields |
idempotency_key | Stable per submission | De-dupe key (upsert) |
Reliability
- Idempotency — every payload carries a 32-char key (also an
X-Braven-Idempotencyheader), so a double-fired form or a retry collapses to one CRM record. Theblr_leadCPT dedupes on the same key. - Signing — if a secret is set, the body is HMAC-SHA256 signed as
X-Braven-Signature, so the CRM can verify authenticity. - Retries — transient failures (timeouts, 5xx, 429) retry with a short backoff; 4xx are terminal. Every attempt is logged with its HTTP status and result.
delivered rows in the Routing Console end-to-end. In production you paste the Smrts
inbound URL into Settings and delete the mock — no code change.GA4 & the data layer
Two layers of measurement. The client pushes a clean event to dataLayer at every
funnel step for GTM to pick up. The server independently fires the money event
(generate_lead) via the GA4 Measurement Protocol on capture — so a conversion is recorded
even when an ad-blocker or a bounce kills the client beacon. That server-side redundancy is the
"server-side tagging a plus" line in your spec.
Client dataLayer events (wire these to GTM tags/triggers)
| Event | When | Key parameters |
|---|---|---|
blr_router_start | Tool loads | — |
blr_buyer_type_selected | Step 1 chosen | buyer_type |
blr_track_selected | Step 2 chosen | track |
blr_qualifiers_answered | Step 3 complete | answers |
blr_lead_routed | Routed view shown | buyer_type, track, intent_tier, outcome |
blr_lead_submitted | Capture succeeds | intent_tier, outcome, lead_id |
The GA client id is read from the _ga cookie and passed to the server event, so the
server-side conversion stitches to the same session in GA4. Recommended GTM setup: a GA4 Event tag per
dataLayer event, keyed on a Custom Event trigger of the same name; map buyer_type,
track, intent_tier, and outcome to registered GA4 custom
dimensions.
Your three screening tests, answered
Speed Test — a searchable video library without tanking page load
My answer, and what this build does: do not reach for a directory plugin. Model videos as a
blr_video CPT with two taxonomies (blr_track, blr_proficiency).
Terms are indexed; one cached WP_Query renders every card into static-friendly markup on
the server. Filtering is then pure client-side show/hide — no AJAX, no per-keystroke query, no
round-trips. Search debounces over text already in the DOM.
How I hold PageSpeed > 90, concretely — by removing weight, not adding plugins:
- Assets enqueue only where the tool/library is used (~6KB CSS + ~7KB JS, no jQuery, deferred).
- The routing matrix is OPcache'd PHP config — no DB hit on the hot path.
- Server-rendered markup means first paint doesn't wait on JavaScript.
- Fonts preconnect +
display=swap; images lazy-load; SVG icons inline (no icon font). - Elementor stays lean — it only loads its runtime on pages actually built with it, and the tool pages are shortcode/widget-rendered.
Routing Test — the conditional logic, form→CRM, and GA4
Covered in full above: the scoring & rules are a declarative matrix with a first-match resolver and a safe default; the form→CRM handoff is an idempotent, signed, retried webhook with a Smrts field map; each step's data pushes cleanly into GA4 client-side, with a redundant server-side conversion. The server always recomputes the decision — the client never dictates the outcome.
Accessibility — WCAG 2.1 AA for civic buyers
Your audiences are government and institutional, so accessible builds are non-negotiable. Built in from the start, not bolted on:
- Each question is a
<fieldset>with a<legend>; options are real<input type="radio">controls inside arole="radiogroup"— fully keyboard operable, correctly announced by screen readers. - Visible focus states on every control (never
outline:nonewithout a replacement). - Step changes move focus to the new step and are announced via an
aria-liveregion; errors are announced too. - Colour contrast meets AA (ink on paper, white on gold verified); state is never colour-only.
- The wizard is server-rendered, so the questions exist without JavaScript; a
<noscript>fallback offers a phone number. prefers-reduced-motiondisables the shake/entrance animations.
Performance summary
No framework
Vanilla JS + PHP. No jQuery, no React, no build step for the front-end.
Conditional assets
CSS/JS load only on pages with the tool. The rest of the site is untouched.
SQLite-friendly
The demo runs WordPress on SQLite — the whole thing is one container, no MySQL.
Cached & indexed
Config is OPcache'd PHP; taxonomy queries are cached; the library filters in-browser.
Operations
Phase 2 — clean seams for your AI team
The spec is explicit: your AI team owns the generative logic; I architect the hooks so it plugs in without a rebuild. The seams are already here:
- Action hook —
do_action( 'blr_lead_captured', $lead, $decision, $lead_id )fires on every qualified lead. An AI feature (e.g. generate a custom syllabus) subscribes to it with zero changes to this plugin. - REST — the
braven/v1namespace is the natural home for a gated/syllabusor/worksheetendpoint that reads the same lead data structures. - Typed data — buyer type, track, tier, and answers are stored as structured meta, so a personalization layer has clean inputs to condition on.
Installing on bravenagency.com
- Upload
braven-lead-routertowp-content/plugins/and activate. (Activation registers the CPTs, creates the delivery table, and seeds the library.) - Partner Leads → Settings: paste your Smrts CRM webhook URL (+ optional secret), GA4
Measurement ID + API secret, GTM ID (
GTM-535B5NR), booking URL, and lead-magnet URL. - Add the tool to any page: drop the Braven Lead Router Elementor widget, or use
[braven_lead_router]. Add[braven_video_library]for the library. - ACF Pro is auto-detected; the shipped field groups appear on the Lead and Video editors — no import
needed (they load from
acf-json/). - Point the child theme at your live fonts/logo if different; nothing else to configure.
Security
- CSRF — the capture endpoint requires a valid WordPress REST nonce.
- Spam — a hidden honeypot field; bots that fill it get a silent 200 and nothing is stored.
- Input — every field is sanitised at one choke point; email is validated; output is escaped.
- Privacy — leads are a private CPT with
show_in_rest => false; PII is never exposed over public REST. Meta auth callbacks requireedit_posts. - Webhook integrity — optional HMAC-SHA256 signature so the CRM can verify each payload.
Built as a working reference for Braven Agency · Levelbrook · Source and tests included · Live demo