One POST fires the entire funnel.
A complete, end-to-end tour of the Braven Lead Router — the routing brain, the data model, the capture pipeline, the CRM handoff, the tracking, and how every piece sits inside a standard WordPress install. Diagrams, worked examples, and real code.
The 60-second version
A visitor answers three questions. A scoring engine ranks their intent and picks the right next step. On submit, one request stores the lead, sends it to the CRM, emails the team and the prospect, and records a conversion in GA4 — with a browser event trail the whole way.
questions to a routed result
Buyer type, goal, and a short qualifier set — under a minute, mobile-first.
POST does everything
Persist + CRM + email + GA4 fire atomically on a single request.
page-builder bloat
Deep logic in custom PHP; assets load only where the tool is placed.
unit tests, no DB
The routing brain is framework-agnostic and tested without WordPress.
The problem it solves
Braven's institutional buyers — a city economic-development office, a county, a chamber, a foundation — are not the same buyer. They have different money, different timelines, and different definitions of success. A single generic “Contact Us” form treats them identically and leaks the ones who were ready to move.
Different buyers
A funded city ready this quarter needs a booking link. A foundation seeking a grant needs a funding conversation. One form can't serve both.
Different intent
“Just exploring” and “I approve the budget” should not land in the same bucket. Intent has to be measured and acted on.
Clean handoff
The lead has to arrive in the CRM already segmented and scored, with a paper trail — not as an undifferentiated email.
So the tool does three things a form can't: it self-segments the visitor, scores their intent transparently, and routes each one to the next step that actually fits — while capturing everything downstream systems need.
Big-picture architecture
One design decision shapes everything: the routing brain has no WordPress dependency. It's plain PHP that takes a selection and returns a decision. WordPress is a thin adapter layer wrapped around it. That's why the logic is trivial to test and reason about, and why the tool is honest to document.
router.js (state machine, focus, dataLayer). Talks to REST. Progressive enhancement — works before JS.Routing_Engine · Lead_Validator · Webhook_Dispatcher · GA4 — zero WordPress deps, unit-tested with php tests/test-routing.php.The front-end depends on the adapter; the adapter depends on the core; the core depends on nothing. Dependencies only ever point downward.
| File | Layer | Responsibility |
|---|---|---|
class-blr-routing-engine.php | pure | Score answers → tier → resolve outcome. The conditional logic. |
class-blr-lead-validator.php | pure | Sanitise + validate a submission (honeypot, consent, required fields). |
class-blr-webhook-dispatcher.php | pure* | Build CRM payload, sign, retry, log. One optional wp_remote_post seam. |
class-blr-ga4.php | pure | Build + send the GA4 Measurement Protocol event. |
class-blr-lead-repository.php | wp | Persist a lead as the blr_lead CPT + meta. |
class-blr-cpt / -fields / -rest / -shortcode / -elementor-widget / -admin | wp | Register data structures, endpoints, placement, and the operator UI. |
class-blr-core.php | wp | Bootstrap + wire everything on plugins_loaded. |
The request lifecycle
Two REST calls, deliberately split. The first computes a routed view with no personal data. The second — only after the visitor commits — captures and fans out. Here's the full sequence.
The questions are already in the DOM (SEO + no-JS resilience). router.js hydrates: manages steps, focus, and pushes blr_* events to the GTM dataLayer.
Sends buyer type + track + qualifier answers. No PII. The server runs the routing engine and returns the decision — tier, outcome, tailored copy, the form fields to collect.
Tailored headline, the buyer's value proposition, and only the right form fields. Fires blr_lead_routed. The visitor fills in contact details.
Contact fields + selection + GA client id + UTMs + the nonce. The server recomputes the decision (never trusts the client) and validates.
One handler orchestrates all four, in order, each degrading safely. Returns the lead id + destination.
Fires blr_lead_submitted and shows the next step (booking link / download / confirmation).
The routing engine, in full
This is the part the “Routing Test” asks about. It's deliberately transparent: every answer carries points, the sum picks a tier, and a short ordered rule list turns (buyer type, tier, funding posture) into an outcome. All of it is config in data/routes.php — the engine just reads it.
Step 1 — Score the answers
| Qualifier | Answer → 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 |
Step 2 — Map the score to a tier
Step 3 — Resolve the outcome (first match wins)
| # | Condition | Outcome | Next step |
|---|---|---|---|
| 1 | Foundation + funding is 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 |
Worked examples
A funded city, ready this quarter, decision-maker, 500+ businesses
120 ≥ 70 → Tier A. No foundation rule applies → outcome Book a call, with the city value prop: “Turn ARPA / CDBG / general-fund dollars into measurable small-business growth your council can point to.”
A budgeted county, this-year timeline, recommender, under-25 businesses
35 ≤ 60 < 70 → Tier B → outcome Request a proposal (the fuller intake that also collects goals).
A foundation seeking a grant — even with otherwise-hot answers
The score might land in Tier A, but rule #1 matches first: a foundation whose funding is seeking or exploring is routed to Funding partnership. A specific rule intentionally beats the generic tier — because for a funder, the right next step is structuring a fundable program, not a sales call.
The code
// class-blr-routing-engine.php — the whole decision, no side effects public function decide( array $selection ) { $score = $this->score( $answers, $buyer_type ); // sum of points + segment weight $tier_key = $this->tier( $score ); // A / B / C by threshold $outcome = $this->resolve_outcome( $buyer_type, $track, $tier_key, $answers ); // first match return [ 'tier' => $tier_key, 'outcome' => $outcome_key, 'headline' => $outcome['headline'], 'form_fields' => $outcome['form_fields'], 'value_prop' => $bt['value_prop'], 'track_label' => $tk['label'], /* … tailored copy … */ ]; }
data/routes.php. The engine never changes; the tests catch a mistake in a second.The data model
Right tool per job. Leads and videos are Custom Post Types (they want an admin UI, search, REST, and ACF). The webhook delivery log is high-volume append-only audit data, so it's a real custom table. Videos get two taxonomies so terms stay indexed and filterable.
- ID bigint
- post_title org — type/track
- _blr_email meta
- _blr_organization meta
- _blr_buyer_type meta
- _blr_track meta
- _blr_intent_tier A/B/C
- _blr_intent_score int
- _blr_outcome meta
- _blr_crm_status meta
- _blr_crm_key idem key
- id bigint AI
- lead_id FK · KEY
- idem_key varchar · KEY
- attempt smallint
- http_status smallint
- result delivered/retry/failed
- message varchar
- created_at datetime
- ID bigint
- post_title / excerpt core
- _blr_video_url meta
- _blr_video_duration meta
- menu_order ordering
- Digital Marketing
- Social Media
- AI
- Beginner
- Intermediate
- Advanced
Why a CPT for leads but a table for deliveries?
A CPT gives leads the entire WordPress ecosystem for free — the admin list, search, capabilities, export, and (critically for Braven's stack) ACF Pro field groups mapping straight onto the same _blr_* meta keys. Delivery attempts are different: many rows per lead, write-heavy, never edited. That's a table with indexes on lead_id and idem_key, created via dbDelta() on activation.
// class-blr-cpt.php register_post_type( 'blr_lead', [ 'public'=>false, 'show_ui'=>true, 'show_in_rest'=>false ] ); // private: no public URL, no public REST register_taxonomy( 'blr_track', 'blr_video', [ 'hierarchical'=>true, 'show_admin_column'=>true ] );
The capture pipeline
One POST to /braven/v1/lead runs four stages in order. Each degrades safely — a failed email never fails capture, an unconfigured CRM is recorded rather than thrown. The lead is captured no matter what downstream does.
// class-blr-rest.php — the orchestration, trimmed public static function post_lead( $req ) { if ( ! self::verify_nonce( $req ) ) return 403; // CSRF $decision = ( new BLR_Routing_Engine )->decide( $selection ); // recompute — never trust client $result = ( new BLR_Lead_Validator )->validate( $params, $decision['form_fields'] ); if ( $result['errors']['_spam'] ) return 200; // silent to bots if ( ! $result['ok'] ) return 422; // field errors $lead_id = ( new BLR_Lead_Repository )->save( $lead, $decision ); // 1. persist $crm = ( new BLR_Webhook_Dispatcher )->dispatch( $lead, $decision, $lead_id ); // 2. CRM ( new BLR_Email_Workflow )->run( $lead, $decision, $lead_id ); // 3. email ( new BLR_GA4 )->generate_lead( $decision, $ga_client_id ); // 4. GA4 return [ 'ok'=>true, 'lead_id'=>$lead_id, 'crm'=>$crm, 'destination'=>$dest ]; }
blr_lead instead of twinning it, and a webhook retry collapses to one CRM record.The CRM handoff
The lead POSTs to a configurable webhook — Braven's 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 and shown in the admin Routing Console.
The payload
{
"idempotency_key": "a1b2…", // dedupe/upsert key (also an X-Braven-Idempotency header)
"lead_id": 30,
"contact": { "name":"…", "title":"…", "email":"…", "phone":"…", "organization":"…" },
"segmentation": {
"buyer_type":"city", "track":"ai",
"intent_tier":"A", "intent_score":120,
"outcome":"book_call", "priority":"hot"
},
"notes": "…goals…", "consent": true,
"attribution": { "utm_source":"…", "gclid":"…" },
"source": "braven-lead-router", "page_url":"…", "submitted_at":"2026-…Z"
}
Mapping to Smrts (or any CRM)
| Payload path | Maps to |
|---|---|
contact.* | Contact / Lead record fields |
segmentation.buyer_type / track | Lead source segment / interest tag |
segmentation.intent_tier + intent_score | Lead score / priority |
segmentation.outcome | Pipeline stage / workflow trigger |
attribution | Source attribution fields |
idempotency_key | De-dupe key (upsert) |
Reliability
Idempotent
Retries and double-submits collapse to one CRM record via the key.
Signed
Optional HMAC-SHA256 over the body as X-Braven-Signature so the CRM can verify authenticity.
Retried
Timeouts, 5xx, 429 retry with short backoff; 4xx are terminal. Every attempt is logged.
delivered rows end-to-end. In production you paste the Smrts inbound URL into Settings and delete the mock — no code change.Tracking & GA4
Two layers. The browser pushes a clean event to dataLayer at every step for GTM. The server independently fires the conversion (generate_lead) via the GA4 Measurement Protocol — so a lead is recorded even when an ad-blocker or a bounce kills the client beacon. That redundancy is the “server-side tagging a plus.”
| dataLayer event | Fires when | Key params |
|---|---|---|
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 |
Wiring it in GTM
For each blr_* event: a GA4 Event tag on a matching Custom Event trigger; map buyer_type, track, intent_tier, outcome to registered GA4 custom dimensions. The GTM container prints into wp_head only when a container ID is set — nothing loads otherwise.
The wizard front-end
Progressive enhancement over server-rendered markup: the questions exist in the DOM before JavaScript runs. router.js is a tiny (~7KB, no jQuery) state machine that handles transitions, focus, REST calls, and the dataLayer.
- Auto-advance on the single-choice steps (buyer type, track) keeps it snappy; qualifiers wait for an explicit “See my path”.
- Focus moves to each new step and errors are announced via an
aria-liveregion. - The routed form is built from the decision — the server tells the client which fields to collect, so the form matches the outcome (a booking confirm asks for less than a proposal intake).
- GA client id + UTMs are read client-side and sent with the capture so server-side GA4 stitches to the same session and the CRM gets attribution.
How it lives inside WordPress
Everything is stock WordPress — no core hacks, no exotic dependencies. One plugin folder in wp-content/plugins/ plus a hello-elementor child theme. Drop it into any WordPress 6.4+ site and activate.
| Custom feature | WordPress primitive it uses |
|---|---|
| Captured leads | CPT blr_lead + post meta |
| Training videos | CPT blr_video + taxonomies |
| CRM delivery log | Custom table via dbDelta() |
| Editable fields | ACF Pro field group (native meta-box fallback) |
| Wizard & capture API | register_rest_route() |
| Placement | Shortcode and Elementor widget (one renderer) |
| Operator UI | Admin submenu + custom list columns |
| Tracking | wp_head dataLayer + wp_remote_post to GA4 |
| Extensibility | Action hook blr_lead_captured |
ACF Pro — a first-class citizen, never a hard dependency
Without ACF, meta is registered/typed and the admin shows a native panel. With ACF Pro (Braven's stack), the shipped field groups in acf-json/ take over the UI, bound to the same _blr_* keys — zero migration. ACF simply upgrades the interface.
Shortcode and Elementor — one renderer
Deep logic in PHP; Elementor stays clean. The “Braven Lead Router” widget carries no logic — it calls the shortcode renderer, so there's exactly one implementation. (Load-order note: the widget subclasses extend \Elementor\Widget_Base, which isn't available at plugins_loaded, so they're declared lazily inside the elementor/widgets/register callback.)
REST API reference
| Endpoint | Method | Body | Returns | Auth |
|---|---|---|---|---|
/wp-json/braven/v1/config | GET | — | buyer types, tracks, qualifiers | public |
/wp-json/braven/v1/route | POST | {buyer_type, track, answers} | {decision:{tier,outcome,headline,form_fields,…}} | public (no PII) |
/wp-json/braven/v1/lead | POST | selection + contact + ga_client_id + utm | {ok, lead_id, crm, destination} | REST nonce + honeypot |
# Try the routing brain live — no auth, no PII: curl -s -X POST https://braven-demo.levelbrook.com/wp-json/braven/v1/route \ -H 'Content-Type: application/json' \ -d '{"buyer_type":"foundation","track":"ai","answers":{"funding":"seeking","timeline":"year","role":"recommend","scale":"m"}}' # → outcome: funding_partnership (the foundation rule beats the tier)
Performance — how PageSpeed stays > 90
By removing weight, never by adding plugins. The whole thing is built to load almost nothing.
Conditional assets
CSS/JS enqueue only on pages that contain the tool. The rest of the site loads none of it.
No framework
Vanilla JS + PHP. No jQuery, no React, no build step. ~6KB CSS + ~7KB JS.
Config is OPcache'd
The routing matrix is plain PHP — no DB round-trip on the hot path.
Server-rendered
First paint doesn't wait on JavaScript; the wizard and library render on the server.
Library filters in-browser
One cached WP_Query; filtering is show/hide over the DOM — no AJAX per keystroke.
Elementor stays lean
It only loads its runtime on pages actually built with it; the tool pages are shortcode-rendered.
Accessibility (WCAG 2.1 AA)
Braven's 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">inside arole="radiogroup"— keyboard operable, screen-reader correct. - Visible focus states on every control (never
outline:nonewithout a replacement). - Step changes move focus to the new step and are announced via
aria-live; errors are announced too. - Colour contrast meets AA; state is never colour-only.
- Server-rendered questions work without JavaScript; a
<noscript>fallback offers a phone number. prefers-reduced-motiondisables the shake/entrance animations.
Security
CSRF
The capture endpoint requires a valid WordPress REST nonce.
Spam
A hidden honeypot; bots that fill it get a silent 200 and nothing is stored.
Input
Every field sanitised at one choke point; email validated; output escaped.
Privacy
Leads are a private CPT with show_in_rest=false; PII never on public REST. Meta auth requires edit_posts.
Server-authoritative
The decision is recomputed on capture — the client can't dictate the outcome.
Webhook integrity
Optional HMAC-SHA256 signature so the CRM verifies each payload.
The deploy stack
The live demo is a single self-contained container: FrankenPHP (PHP 8.4) serving a real WordPress + Elementor install on SQLite — no MySQL — behind an auto-TLS reverse proxy.
- First boot self-provisions headlessly via WP-CLI: installs core, the SQLite integration, Elementor, the plugin, and the child theme, then seeds pages, the menu, the video library, and settings.
- The SQLite database lives on a mounted volume, so it survives image rebuilds.
- Assets aside, the whole demo is reproducible from the repo:
docker build→docker run→ register the proxy route.
Testing
The routing brain has no WordPress dependency, so the entire contract is unit-tested with plain php tests/test-routing.php — 26 assertions, no database, no HTTP server.
| Covers | Examples |
|---|---|
| Scoring | hot answers + city weight = 120; cold + chamber = 12 |
| Tier boundaries | 70→A, 35→B, 34→C |
| Decisions | city/AI hot → book_call; county mid → request_proposal; chamber cold → nurture |
| Rule precedence | foundation + seeking → funding_partnership (beats Tier A) |
| Robustness | garbage input never throws → defaults to nurture |
| Validation | bad email rejected; missing consent rejected; honeypot caught; script tags stripped |
| CRM payload | 32-char idempotency key; stable for identical input within the hour |
Extending it — the Phase 2 AI seam
Braven's spec: their AI team owns the generative logic; the engineer architects 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. a custom syllabus) subscribes with zero plugin changes.
REST namespace
braven/v1 is the natural home for a gated /syllabus or /worksheet endpoint reading the same lead data.
Typed data
Buyer type, track, tier, and answers are structured meta — clean inputs for a personalization layer to condition on.
Glossary
| Term | Means |
|---|---|
| CPT | Custom Post Type — a WordPress content type beyond posts/pages (here: leads, videos). |
| ACF (Pro) | Advanced Custom Fields — the field-UI plugin Braven uses; the plugin ships field-group exports. |
| Taxonomy | A WordPress classification system (here: track, proficiency for videos). |
| dataLayer | The array GTM reads; the wizard pushes funnel events onto it. |
| Measurement Protocol | GA4's server-to-server event API — used for the redundant conversion. |
| Idempotency key | A stable hash so retries/double-submits don't create duplicates. |
| Intent tier | A / B / C bucket derived from the qualifier score. |
| FrankenPHP | A modern PHP app server (Caddy + PHP) running the demo container. |