DemandNow·Free Audit Integration — Engineering Spec
v0.9
DemandNow.ai · Engineering Specification

Free Audit Integrationsubmit → confirm → watch it run → report

Turn the homepage "free audit" button into a real automated GEO audit that runs live in front of the visitor — a progress bar on a demandnow.ai page, an on-brand report the moment it finishes.

✓ Stages 0–8 complete ✓ Full E2E passed on staging ◷ Stage 9 — production go-live remains
Status
v0.9 — staging-verifiedLast revised 2026-05-18
Authors
Andriy Kozlovsky+ Claude
Created
2026-05-179 revisions · v0.1 → v0.9
Repos affected
demandnow-frontend-redesign+ free-audit
Methodology
Spec-driven3 red-team passes

Implementation progress

9 of 10 stages
0 OPS
1 FA
2 FA
3 WP
4 WP
5 WP
6 WP
7 WP
8 OPS
Part I

Orientation

What this project does, how to read this document, and the vocabulary the rest of the spec assumes.

§ 01 — Goal

Overview #

Today the homepage "Get a free audit" button opens a lead modal that only emails the team. A human then runs an audit by hand. This integration makes that button trigger the real automated GEO audit and run it interactively, in front of the visitor.

The audit executes on a demandnow.ai page with a live progress bar, and the branded report renders on that same page the moment it finishes. The lead's data is captured and the sales team is notified — no human runs anything by hand.

Success looks like

A visitor submits the audit form, confirms their email, clicks "Start my audit," then watches the audit run on that page — a bar moving through reading your site → working out your keywords → checking how Gemini ranks you. About a minute later the on-brand report appears on the same page: where their brand is invisible in Gemini's AI answers, their site health, their SEO crawl issues — ending in a "book a strategy call" CTA. A durable copy lives at /audit-report/{token} and is emailed to the lead, so closing the tab loses nothing.

×Non-goals — out of scope for v1

The spec is as much about what is not built as what is. These are deliberate exclusions:

Re-platforming WordPress
The site stays on Hostinger shared hosting. No infrastructure migration.
Changing the strategy-call flow
intent=call keeps today's email-only behavior, untouched.
A ChatGPT / OpenAI ranking engine
v1 is Gemini-only (D8). OpenAI's paid web_search tool costs ~45¢/audit → deferred to v2.
A recommendations generator
The fix roadmap is the human strategy call — the tool produces diagnostics, not prescriptions.
A customer login / dashboard
No audit history, no lead account, no self-service re-run.
SSE / websocket streaming
v1 uses short-polling — it survives Hostinger + LiteSpeed. free-audit's internal SSE stream is not consumed by demandnow.ai.
Lead-facing cancellation
free-audit has a cancel flow on its own UI; v1 surfaces no cancel control on demandnow.ai.
Migrating free-audit's operator UI
The operator dashboard and /start/{token} flow stay exactly as-is.
§ 00 — Method

How this document works #

This is a spec-driven development artifact. Implementation agents were handed this document — not a chat log. Three rules govern it.

01Locked vs. open
The Decisions section is locked. Open Questions is the only place unresolved items live. No agent runs until there are no open questions — an agent hitting an undecided fork must stop and surface it.
02What vs. how
Sections specify what the system must do and the contracts between repos. Where a section says "implementer's choice," the agent picks the how within stated constraints.
03Living document
When implementation reveals the spec is wrong, the spec is updated first, then the code follows. Every change bumps the version and adds a Changelog row.

#The track tags

Every section is labelled with where the work lands. Use the Filter by track control in the sidebar to focus on one.

TagMeaningRepository / surface
WPWordPress theme workdemandnow-frontend-redesign — the demandnow-redesign theme
FAAudit-engine workfree-audit — the Python audit pipeline on the VPS
OPSDeployment / infraNo repo change — DNS, nginx, TLS, plugins, cron
i
About this HTML

This page is a human-readable rendering of the canonical spec, docs/audit-integration-spec.md (v0.9). The Markdown file remains the source of truth for implementation agents; this version reorganizes the same content for reading — grouped into six parts, with the verbose red-team detail tucked into collapsible panels.

§ 02 — Vocabulary

Glossary #

Terms the rest of the spec uses precisely. Skim once; refer back when a section leans on one.

GEO / AEO
Generative / Answer Engine Optimization — being visible in AI-generated answers.
Audit
One run of the free-audit pipeline against one URL: scrape → extract brand/keywords → query the AI engine for rankings → PageSpeed Insights → SEO crawl. ~1 min — the ranking step runs keywords concurrently.
Engine
An AI model the audit queries for brand rankings. v1 = Gemini only.
Lead
A person who submitted the audit form. Stored in WordPress as an audit_lead post.
free-audit token
The unguessable String(64) token from AuditAccessToken. One per audit; doubles as the credential to read that audit's results.
Verify token
A WordPress-generated token (32 hex chars) emailed to the lead. Its link opens the live audit page. Retained for the life of the lead.
Live audit page
The themed page the verify link opens (/audit-verify/{token}). One URL, multiple visitor states — confirm → live progress → report → expired → failed. Hosts the whole visitor-facing experience (D11).
Stage
free-audit's coarse pipeline phase, the Audit.stage column: scraping · extracting · ranking. The basis for the progress bar.
Progress poll
The browser script on the live audit page polling WordPress every ~5 s; WordPress proxies free-audit's JSON API.
Findings
The diagnostic data the audit produces — rankings, scores, crawl issues. Shown free.
Roadmap
The prioritized set of fixes. Not produced by the tool — it is the deliverable of the strategy call. Gated.
Part II

The Plan

The locked decisions, the verified facts about both systems today, the target architecture, and the funnel that ties it together.

§ 04 — Decisions🔒 Locked

Decisions #

Twelve locked decisions. These are settled — an implementation agent does not relitigate them. Each carries the rationale that closed it.

D1Interactive, on-page audit
Form submit → verify email → the verify link opens a page where the visitor starts the audit, watches it run live, and sees the report appear — all on one page. The audit takes only ~1 min; keeping the visitor engaged converts far better than "we'll email you."
D2Verified email gate
The audit runs only after the lead confirms their email. Each audit costs money on a public form.
D3free-audit on the existing Docker VPS
At audit.demandnow.ai. Hostinger shared hosting cannot run the stack. Verified: the stack is already deployed and running on the VPS.
D4Native report on demandnow.ai
free-audit exposes a JSON API; WordPress renders the report — inline on the live page and at the durable /audit-report/{token}. On-brand, retargeting intact, no PDF.
D5Hybrid report depth
Show all findings; the fix roadmap is the strategy call. The tool produces diagnostics, not prescriptions.
D6Transactional email via an SMTP provider
Brevo, wired through an SMTP plugin. The verify email is funnel-critical; Hostinger's shared mail() spam-folders.
D7Leads stored as a WordPress CPT
An audit_lead custom post type with a funnel status. No external CRM in v1 — a self-contained admin dashboard, the funnel as a state machine.
D8Gemini-only for v1
No OpenAI/ChatGPT engine. OpenAI's paid web_search tool ≈ 45¢/audit; Gemini-only ≈ $0.15/audit. ChatGPT → v2.
D9Verify link opens the page; a POST triggers the audit
A bare emailed GET is prefetched by mail scanners and would fire paid audits unbidden. The GET is side-effect-free; a "Start" button POST spends.
D10Ships bundled with the theme's production cutover
One go-live event. Built and tested on staging meanwhile. The SMTP plugin is not theme code — installed separately.
D11Progress + report on the verify page itself
/audit-verify/{token} is the whole visitor experience: confirm → live progress → report. WordPress short-polls free-audit's JSON API. No SSE to the browser — a long-lived SSE proxy through PHP on Hostinger + LiteSpeed is fragile.
D12location / physical_locations_count collected in v1
The form asks local businesses for their location; it flows through to free-audit, which localizes the ranked keywords. free-audit already supports it fully — the cost is one WordPress field group.
Resolved open questions § 18 — all 9 closed, v0.6

All open questions were resolved before implementation began (§0 rule 1). For the record:

#QuestionResolution
Q1VPS connection / environmentSSH alias myvps149.50.146.215:5854. free-audit already deployed & running — Stage 0 reduces to DNS + nginx + TLS.
Q2Subdomain + DNS controlConfirmed — audit.demandnow.ai; DNS controlled at Hostinger. A record created in Stage 0.
Q3Team recipient + From mailboxTeam notice → andriy.dev005@gmail.com (staging); real sales inbox at Stage 9. From → hello@demandnow.ai.
Q4DAILY_COST_CAP_USD launch valueKeep $20 (≈130 Gemini audits/day).
Q5AI Visibility Score formula + bandsApproved as written — mentioned / total_pairs × 100; bands 0–10 / 11–30 / 31–60 / 61–100.
Q5bDe-ChatGPT'd homepage copyApproved — "…where you're invisible in Google and Gemini's AI answers, and the fastest wins to fix it."
Q6Change strategy per repoBoth repos commit directly to main — no feature branches, no PRs.
Q7Transactional email providerBrevo — free 300/day, native FluentSMTP connector, DKIM enforced. Hostinger Email/Titan rejected.
Q8Peak concurrency / VPS sizing~20 audits/day; ~1-min audits ⇒ peak concurrency 1–3; free-audit ceiling 4 concurrent. VPS adequate.
§ 03 — Current state WP FA OPS

Current State #

Facts confirmed by inspection — the ground every later section stands on. Three subjects: the WordPress theme, the free-audit engine, and the VPS that hosts it. Nothing here is aspirational; each line was verified by reading code or by read-only recon on 2026-05-17 / 2026-05-18.

🔒
Verified, not assumed

free-audit is already deployed and running on the VPS — 5 containers, up 10–11 days, with real audit history in Postgres. The location and physical_locations_count columns already exist (migration 0004) — no free-audit schema change is needed for them. Stage 0 is therefore DNS + nginx + TLS, not a fresh deploy.

3.1WordPress theme — demandnow-redesign

The existing lead-capture path that this integration extends — verified by inspection 2026-05-17.

Lead modal
Lives in front-page.php: #leadModal (line 309), #leadForm (341), hidden #leadIntent (346), #website (364). Audit-variant copy at 310–313 currently promises "across Google, ChatGPT, and Gemini" (§8.6). Fields: name*, email*, company*, website (optional), revenue*, message, intent (audit|call), company_url (honeypot).
Submission JS
assets/js/theme.js (~98–143): fetch() POSTs JSON to window.demandnowAjax.restUrl with X-WP-Nonce. Modal logic ~145–213. On res.ok && body.ok it shows body.message — falling back to the call copy if message is absent (§9.4).
REST endpoint
inc/form-handler.php registers POST /wp-json/demandnow/v1/contact. Verifies the wp_rest nonce, honeypot, per-IP rate limit (5/hour, transient demandnow_lead_{md5(ip)}), then wp_mail(). Plain-text; sets only Reply-To ({name} <{email}>), never From (form-handler.php:61-64).
Asset enqueue
functions.phpdemandnow_redesign_assets() — mtime-versioned; wp_localize_script('demandnow-theme','demandnowAjax',{restUrl,nonce}). Modules in inc/ are required at the end of functions.php.

Rewrites, head & security

rewrite
functions.php:182-196 does add_rewrite_rule() on init with no flush — relies on a manual wp rewrite flush. There is no after_switch_theme hook today.
head
GTM hardcoded in header.php:13-28. Robots / canonical meta are owned by Yoast.
security
inc/security.php:14-15 sets a site-wide Cross-Origin-Embedder-Policy: require-corp.
host
Hostinger shared hosting; no Docker; wp-config.php must not be edited (CLAUDE.md §11). LiteSpeed Cache full-page-caches aggressively.
Environment

The redesigned theme is active on staging only (staging.demandnow.ai, separate DB). Production runs the old theme; the theme's prod cutover is a separate pending approval (CLAUDE.md §2, §9). Active plugins (CLAUDE.md §6): Elementor, LiteSpeed Cache, Yoast SEO, Code Snippets (may hold custom PHP — inspect before relying on mail behavior), WP Headers and Footers.

3.2Audit engine — free-audit

The Python audit pipeline. Already built and running; v1 work on it is purely additive.

Stack
Python 3.12, FastAPI + uvicorn (web), arq worker (worker), Postgres, Redis, Playwright (browser). 5 services in docker-compose.yml. web binds 127.0.0.1:${WEB_HOST_PORT}.
Migrations
Alembic. web/main.py lifespan (~21–32) runs command.upgrade(cfg,"head") before the app serves — so /healthz going green implies migrations finished. The worker does not run migrations today (worker/main.py on_startup = reconcile_orphans only). SessionLocal is expire_on_commit=False (core/db.py).
Pipeline
worker/jobs.py run_audit (~450–498): scrapingextractingranking, with PSI + crawl as parallel non-fatal side-tasks. arq job; ~1 min wall-clock — ranking ranks keywords concurrently (KEYWORD_PARALLELISM default 5), so AuditResult rows commit in small bursts, not strictly serially.
Routes & budget
web/routes.py: operator dashboard (password session) + public single-use /start/{token} flow. No machine API, no JSON output, no API-key auth. SessionMiddleware app-wide; no CORSMiddleware. DAILY_COST_CAP_USD default $20; _check_budget exists only inside the worker — no pre-flight check on any web route.

Data model — core/models.py

Entities: Audit, AuditKeyword, AuditResult, AuditPSI, AuditCrawlPage, DailySpend, AuditAccessToken.

Column / entityDetail
Audit.stageString(32), nullable — values scraping | extracting | ranking (migration 0002_audit_stage). Basis for the progress bar.
Audit.locationString(255), nullable (migration 0004_locations_and_tokens) — already exists.
Audit.physical_locations_countInteger, default 0 (migration 0004) — already exists.
Audit.started_atServer func.now() at row creation.
Audit.finished_atNullable; terminal-only. Also on Audit: cancel_requested, total_cost_cents, error, scraped_text, brand_detected, category_detected.
AuditAccessTokentoken (String(64), unique, indexed), email (nullable), created_at, used_at (nullable), audit_id FK ON DELETE SET NULL, nullable.
PSI columnsScore / vitals are Numeric → Python Decimal. Audit has no back-relationship to AuditAccessToken; operator-run audits (POST /audits) have no token at all.
AuditResult.full_top10A comma-joined "1. Asana, 2. Notion" string (core/ranker.py _format_top10) — not JSON.

Incremental commits — what is observable

DataCommit patternObservable as
AuditResultOne commit per keyword-engine pair as ranking proceeds (_rank_step)A row count → ranking progress
AuditKeywordOne batch when extraction finishesstage change only
AuditPSISingle commit at end of its side-taskstage change only
AuditCrawlPageSingle commit at end of its side-taskstage change only

The operator dashboard (/audits/{id}) and public /start/{token} flow already render a live run — web/templates/audit.html + HTMX's SSE extension subscribe to GET …/stream, which replays a Redis log stream (audit:{id}:log) and emits event: done. v1 does not consume that SSE stream (cross-origin, operator-authed) — it polls the JSON API (§10.2, §12.2).

Location plumbing — already end-to-end

Both POST /audits and POST /start/{token} accept location / physical_locations_count via _parse_audit_fields (web/routes.py ~54–75): count < 0400; count > 0 requires a non-empty location → else 400; count == 0 forces location to NULL. When count > 0 and location is set, worker/jobs.py _localize (~174–184) appends " in {location}" to every extracted keyword before storage and ranking. No free-audit schema or pipeline change is needed for v1 location support.

Config & deploy assets

config
core/config.py: ENGINES = ("gemini",) (line 6); Settings (Pydantic BaseSettings); MAX_KEYWORDS_PER_AUDIT = 15; assert_production_safe() today checks only APP_PASSWORD and SESSION_SECRET (§15 extends it).
reports
web/reports.py: summarize(), crawl_highlights(), group_by_keyword(). No PSI helper.
migrations
A healed branch (0004_audit_psi and 0004_locations_and_tokens); the current single head is 0007_merge_heads.
nginx
deploy/nginx/free-audit.conf exists but is pinned to a different hostname/port and to nginx zones defined elsewhere — a template, not a drop-in.

Load-bearing traps

finished_at is an unresolved SQL expression

Terminal status is set in _audit_lifecycle via _set_terminal, which writes finished_at = func.now()a SQL expression, not a Python datetime. With expire_on_commit=False, the in-memory attribute keeps the unresolved func.now() object after commit. Anything reading finished_at off a session-cached row must re-SELECT to get a real timestamp.

A real running + stage=NULL window exists

status is committed running on context entry, but the first _set_stage("scraping") runs after a budget + cancel check — so a genuine status=running + stage=NULL window exists. §12.2's progress derivation must tolerate it.

stage is never reset after ranking

stage stays "ranking" through the PSI/crawl side-tasks and the terminal commit — it is never cleared. So stage alone cannot distinguish "ranking in progress" from "done"; §12.2 derives that from AuditResult row counts.

reconcile_orphans flips running→failed with no webhook

worker/main.py reconcile_orphans runs on every worker restart and bulk-flips running → failed without going through run_audit — so it fires no webhook. The §12.5 reconciliation cron and §12.6 routine exist to catch leads stranded this way.

3.3Audit VPS — the free-audit host

Read-only inspection, verified 2026-05-18. free-audit is already deployed and running — Stage 0 (§16) is not a fresh deploy; it is DNS + nginx + TLS for the new hostname, plus the §11.2 env vars.

5 / 5
services up 10–11 days
2 vCPU
7.8 GiB RAM, no swap
81 %
disk full · ~4.8 GiB free
0004
live Alembic head
connection
SSH config alias myvps → host 149.50.146.215, port 5854, user root, key ~/.ssh/id_ed25519 (path only — the private key never enters this repo or git).
OS / resources
Ubuntu 24.04.4 LTS; 2 vCPU; 7.8 GiB RAM (~5 GiB free, no swap); 25 GB disk. Docker 29.1.2 + Compose plugin v5 (docker compose; no standalone docker-compose).
free-audit state
The 5-service stack (web, worker, browser, postgres, redis) up 10–11 days. web published on 127.0.0.1:8004; /healthz returns {"status":"ok"}.
current URL
https://vps-4849885-x.dattaweb.com:3007 — the nginx free-audit block listens on :3007 for the VPS's own hostname. nginx 1.24 running with :80/:443 free for a new server block.
worker concurrency
VPS .env: MAX_KEYWORDS_PER_AUDIT=15; KEYWORD_PARALLELISM=5 (asyncio semaphore — up to 5 keyword lookups concurrent within an audit, why a run finishes in ~1 min); MAX_CONCURRENT_AUDITS_PER_WORKER=4 (arq max_jobs). One worker container; no Docker-level horizontal scaling.
Deployed DB is behind the repo

alembic current on the live web container reports head 0004_locations_and_tokensbelow the repo head 0007_merge_heads (§3.2). The first post-Stage-2 redeploy's boot-time alembic upgrade head therefore applies 0005 / 0006 / 0007_merge_heads and the new 0008 (§11.1) in one step — not the single-migration jump first assumed. The Postgres DB is live with real audit history — it is not fresh, which bears on the 0008 duplicate pre-flight (§11.1).

Disk is 81% full

25 GB disk, ~4.8 GiB free — an ops watch item (§14). The VPS also runs several unrelated stacks (techseoaudit-cloud, wp-tools, demandnow-coaching, dmnow-app, …) that share its 2 vCPU — fine at v1's low concurrency, but a sizing watch item if volume grows.

No audit.demandnow.ai yet

There is no audit.demandnow.ai server block, TLS certificate, or DNS recordaudit.demandnow.ai is currently NXDOMAIN. The apex demandnow.ai points at Hostinger (185.249.224.51), not the VPS. Creating all three is the substance of Stage 0.

§ 05 — Architecture WP FA OPS

Target Architecture #

The whole integration in one picture — eight steps across three actors, the additive build list per track, and the contract that keeps the two repos independent.

5.1End-to-end flow

One audit, start to finish, across three actors: the visitor's browser, WordPress on demandnow.ai (Hostinger), and free-audit on audit.demandnow.ai (the VPS). Every numbered step shows exactly what crosses a lane boundary — the HTTP calls, the verify email, the HMAC webhook, the progress poll.

VISITOR — browser WORDPRESS — demandnow.ai FREE-AUDIT — audit.demandnow.ai 1 Fills audit modal & submits incl. location if it has physical stores POST /wp-json/demandnow/v1/audit • nonce + honeypot + rate limit • create audit_lead • generate verify token • wp_mail() → SMTP: verify link pending ✉ verify email → lead's inbox 200 2 Sees "Check your inbox" { ok:true, message:"…" } 3 Clicks verify link from the email GET /audit-verify/{verifyToken} CONFIRM STEP — no side effects, prefetch-safe 4 Clicks "Start my audit" the spend-triggering POST POST /audit-verify/{verifyToken} • ATOMIC claim: _dn_status pending → running • server-to-server, X-API-Key → • store FA token (verify token KEPT) • on non-201: revert → pending, keep verify token running POST /api/audits {url, email, location, physical_locations_count, callback_ref, webhook_url} validate → Audit + AuditAccessToken in ONE txn; enqueue job 201 {audit_id, token, status} live page 5 Browser polls ~5 s bar advances: scraping → extracting → ranking N/M → final GET /wp-json/demandnow/v1/audit/status/{tok} • proxy GET /api/audits/{token} with X-API-Key • ~6 s transient cache — no FA hammering /api/audits/{token} {status, stage, progress} worker runs the pipeline: scrape → extract → rank → psi → crawl terminal status committed; re-SELECT state… POST webhook_url canonical-string HMAC webhook {event, token, audit_id, status, callback_ref, finished_at} • verify HMAC + freshness window • dn_audit_apply_completion() idempotent — §12.6 6 Poll sees state=complete JS reloads the verify page /audit-verify/{verifyToken} reload FULL REPORT rendered inline "bookmark: /audit-report/{token}" • lead status → viewed • completion sends results email + team notice — exactly once (guard) viewed report 7 Later / closed tab opens the durable link from the results email GET /audit-report/{token} durable, uncacheable input — same report, rendered in theme, cached 1h report 8 Clicks "Book a call" the report's closing CTA existing #leadModal (intent=call) unchanged path — emails the team
§5.1 — One audit across three actors. Green arrows = browser ↔ WordPress; navy = WordPress responses to the browser; the red arrow is free-audit's HMAC-signed webhook back to WordPress. The free-audit API key crosses only the WordPress ↔ free-audit boundary, never the browser lane.
The visitor normally never needs the email

They watch the audit and read the report in one sitting. The results email and the durable /audit-report/{token} page exist for the visitor who closes the tab, and for the team. If the visitor closes the live page mid-run, the audit still finishes and the webhook (§12.3) / reconciliation cron (§12.5) complete the lead and send the email.

5.2What gets built

Three groups of work, one per track. Every item is purely additive — no existing behavior is removed or rewritten.

FAfree-audit
Existing dashboard & /start/{token} untouched.
  • API-key auth dependency
  • POST /api/audits (incl. location / physical_locations_count)
  • GET /api/audits/{token} — incl. audit.stage + a live progress block
  • core/webhook.py dispatcher
  • callback_ref + webhook_url columns + a unique index on audit_access_tokens.audit_id
  • worker self-migration + boot config assertion
  • redeploy the updated stack onto the already-running VPS
No location columns — they already exist (§3.2).
WPWordPress
New inc/ module(s); existing /contact flow untouched.
  • audit_lead CPT
  • POST /wp-json/demandnow/v1/audit
  • /audit-verify/{token} — GET confirm step + live audit page + report; POST trigger
  • GET /wp-json/demandnow/v1/audit/status/{token} — browser progress poll
  • POST /wp-json/demandnow/v1/audit/webhook
  • /audit-report/{token}
  • shared completion/failure routines (§12.6) + shared report renderer
  • results + team emails
  • audit-form changes (incl. the location fields) + homepage copy
OPSOperations
No repo change.
  • VPS exposure for audit.demandnow.ai — DNS + nginx + TLS (free-audit already runs, §3.3)
  • transactional-email provider + SMTP plugin (staging & prod)
  • LiteSpeed exclusions — the live audit page, the report page, and the status endpoint must never be cached
  • hPanel system cron

5.3Repo boundary / contract

No shared database

Three free-audit ↔ WordPress HTTP contracts (§10) — plus one internal browser → WordPress progress endpoint (§12.2) that merely proxies the second contract. One free-audit instance serves both the staging and production WordPress sites; it calls back to the per-request webhook_url.

WordPress owns
Leads — the audit_lead CPT, funnel status, emails.
free-audit owns
Audit data — the pipeline, results, scores, crawl pages.
primary join key
The free-audit token.
callback_ref
lead_{post_id} — a signed secondary key back to the WordPress lead.
API key
Never reaches the browser — every browser → free-audit interaction is brokered server-to-server by WordPress.
§ 06 — State machine WP

The Funnel State Machine #

Every audit_lead carries a funnel status in the post meta key _dn_status. Six states, a handful of transitions — all one-way and idempotent, with a single deliberate exception.

pending verify email sent running audit in flight complete findings ready viewed report seen expired link unused 48h failed audit errored / halted confirm POST wins claim free-audit call fails (non-201) → revert completion observed webhook | poll | cron — §12.6 report rendered verify token expires (48h) failed / halted / cancelled observed SUPERSESSION late audit.completed for a failed lead normal transition failure / revert supersession — the one exception to "one-way"
§6 — The _dn_status funnel. The dashed green edge is the sole non-monotonic transition: a late audit.completed may pull a failed lead forward to complete.

6Status reference

StatusSet whenLead seesTeam sees
pending Form submitted (verify email sent); or reverted here after a failed free-audit call The confirm step of the live audit page
running Confirm POST won the atomic pending→running claim (§12.1) The live audit page — progress bar advancing in real time
complete Completion observed — audit.completed webhook, the §12.2 progress poll, or the §12.5 cron (dn_audit_apply_completion, §12.6) The report (inline on the live page; the reload renders it) Team notification + findings
failed A failed / halted / cancelled outcome observed via the same three channels (dn_audit_apply_failure, §12.6) "We hit a snag — we'll be in touch" (inline) + soft-failure email Failure notification
viewed The report is first rendered — on the live audit page's complete branch, or at /audit-report/{token} The report Admin column updates
expired Verify token unused 48h, lead still pending (evaluated lazily on access) "Link expired — request a new audit" Admin column updates

6Transition rules

One-way & idempotent — with one exception

Every transition is one-way and idempotent, except: a late audit.completed for a lead currently failed is allowed to supersede it (failed → complete) — this covers the worker-restart re-queue case (§10.3).

complete and viewed are never downgraded to failed: a duplicate or stale failure for a completed lead is a logged no-op. A duplicate completion for an already-terminal lead in the same direction is a logged 200/no-op.

Because completion can be observed by three independent channels — webhook, progress poll, and cron — all three funnel through the single idempotent routine in §12.6, whose atomic guards make the status transition and the email send each happen exactly once regardless of which channel arrives first.

Part III · WP

The WordPress Build

Everything that lands in the demandnow-redesign theme — the lead store, the form, the submission endpoint, the verify/progress/webhook machinery, and the emails.

§ 07 — Lead store WP

Audit Lead — Custom Post Type #

Module inc/audit-lead.php (required from functions.php) registers the audit_lead CPT, defines all PII-carrying post meta, surfaces admin columns, and wires the privacy exporter/eraser hooks.

7.1Registration

CPT args
public
false
publicly_queryable
false
exclude_from_search
true
show_ui / show_in_menu
true
show_in_rest
false
supports
['title']
icon
dashicons-chart-bar
label
Audit Leads
Post title format
"{name} — {website host}" — set on creation; never updated after that. post_name is left as WordPress's default slug — do not force it (red-team v0.3 C1 / §9.2 email-dedupe index note).
Admin-only — PII inside

Use capability_type => ['audit_lead','audit_leads'] with map_meta_cap => true. Grant the generated caps only to the administrator role. The CPT is read-only in the UI — no new/edit — leads are immutable once created.

Yoast sitemap exclusion

Assert via wpseo_sitemap_exclude_post_type (or equivalent) that audit_lead is excluded from the Yoast XML sitemap. Private CPTs are normally excluded automatically, but assert it explicitly as a guard.

7.2Post-meta schema

Meta keyNotes
_dn_name, _dn_company, _dn_revenue, _dn_messageStrings — basic lead profile fields.
_dn_emailSanitized + validated with sanitize_email() / is_email(). The dedupe lookup key (§9.2).
_dn_websiteNormalized URL (§9.3). The URL that is actually audited.
_dn_locationCity/region string. Empty when _dn_physical_locations_count is 0 (§8.7). Discarded server-side when count is 0.
_dn_physical_locations_countNon-negative integer. 0 for pure-online businesses (§8.7). Absent/blank coerced to 0.
_dn_statusFunnel status (§6). Written only via the atomic claim described in §12.1 / §12.6.
_dn_verify_token32 hex chars (bin2hex(random_bytes(16))). Retained for the life of the lead as the live-audit-page key — never cleared when the audit starts (§12.1).
_dn_verify_expiresUnix timestamp: submit time + 48 h. Gates only a still-pending lead.
_dn_claimed_atUnix ts when the atomic pending→running claim succeeded (§12.1). Lets the GET handler distinguish a fresh claim from a stuck "claimed but free-audit call never landed" lead.
_dn_verified_atUnix ts of email-link click.
_dn_completed_atUnix ts. Written before _dn_status flips to complete — see gotcha below.
_dn_viewed_atUnix ts set when the report is first rendered.
_dn_audit_tokenThe free-audit access token. May be backfilled from the webhook (§12.3).
_dn_audit_idfree-audit numeric audit id.
_dn_emails_sentCompletion-email done marker — set to '1' only after both emails send successfully (§12.6). Absent or ≠ '1' means the §12.5 cron re-drives the send. Not a concurrency primitive — the lock is the §12.6 wp_options atomic claim.
_dn_failure_notifiedFailure-email done marker. Same model as _dn_emails_sent.
_dn_submit_ipSubmitting IP address — used for abuse triage only.
_dn_report_cacheLast successful results JSON. Written once on first fetch; acts as a fallback if free-audit is unreachable (the 1 h transient in §12.4 is the primary cache).
Write-order guarantee

_dn_completed_at is written before _dn_status flips to complete (§12.6). Any reader that sees status = complete is therefore guaranteed to find the timestamp. Likewise, _dn_verify_token is retained for the full life of the lead — it is the live-audit-page credential and must not be cleared when the audit starts. Finally, _dn_emails_sent and _dn_failure_notified are done markers, not concurrency locks — the lock lives in a wp_options atomic claim (§12.6). Neither key is pre-seeded to '0'; they simply do not exist until a send succeeds.

7.3Admin UI

Columns
Status (colored pill), Email, Website, Location, Submitted (sortable), Report link. All read-only — no inline editing.
Detail meta box
Read-only rendering of all _dn_* meta plus a direct link to /audit-report/{token}. No save handler needed — leads are immutable.
Privacy exporter / eraser hooks may defer to v1.1

Register WordPress personal-data exporter and eraser hooks keyed by email (wp_privacy_personal_data_exporters / wp_privacy_personal_data_erasers). The exporter surfaces all _dn_* meta; the eraser nulls PII fields (_dn_name, _dn_email, _dn_submit_ip, etc.) without deleting the post, preserving funnel analytics.

If deferred to v1.1, note it in the Changelog and leave a // TODO v1.1: privacy hooks comment in inc/audit-lead.php.

§ 08 — Form & copy WP

Audit Form & Homepage Copy #

Three files carry all form changes: front-page.php (markup), assets/js/theme.js (client-side routing & variant logic), and functions.php (script localization). No new files — all additions are in-place.

8.1 — website required for audits
applyVariant() in theme.js: audit variant sets #website to required and updates its label to "Website *". The call variant removes both. Server-side validation (§9) is the real gate.
8.2 — Intent-based routing
The submit handler branches on the hidden intent field: audit → POST to the new audit endpoint (§9); call → POST to /contact, exactly as today. No change to the call flow.
8.3 — Audit success copy
On a successful audit submission, display the message string returned by the endpoint — do not use hardcoded copy, as the endpoint's message varies (normal flow vs. dedupe re-send).
8.4 — Localize auditUrl
Add auditUrl: rest_url('demandnow/v1/audit') to the demandnowAjax object. Reuse the existing wp_rest nonce. The §12.2 status-poll URL is not localized here — the live audit page constructs it from the verify token itself.
8.5 — Unchanged fields
revenue and message are kept exactly as-is — no markup, label, or validation changes for either field.

8.6Homepage copy — Gemini, not ChatGPT

🔒
Approved copy — Q5b locked 2026-05-18

v1 audits Gemini AI visibility, site health, and SEO crawl — no ChatGPT engine exists in v1 (D8). Remove all ChatGPT promises from audit-intent copy. Known location: front-page.php:312 data-audit-sub. Grep the entire theme for "ChatGPT" and fix every audit-context occurrence.

Final approved wording: "…where you're invisible in Google and Gemini's AI answers, and the fastest wins to fix it."

Naming "Google" alongside "Gemini" is accurate and intentional: Google's AI Overviews and AI Mode are Gemini-model-powered, so a Gemini-ranking audit substantiates the "Google AI answers" claim. No separate Google engine is implied or required by this copy.


8.7Local-business inputs (D12)

These fields appear only for intent=audit

Both inputs are hidden when intent=call. applyVariant() controls visibility. Keep copy reassuring and skippable — most SaaS leads will leave the count at 0.

physical_locations_count
Type
number input
Constraints
min=0, default 0
Label (example)
"How many physical store / office locations do you have?"
Required?
No — a pure-online business leaves it at 0
location
Type
text input
Label
"Primary location (city or region)"
Required?
Yes, iff physical_locations_count > 0 — toggled live on the count field's input event via applyVariant()
When count = 0
Optional; discarded server-side (stored as '')
Mirror free-audit's _parse_audit_fields exactly

The client-side required-toggle mirrors free-audit's _parse_audit_fields logic so the two systems never disagree on what constitutes a valid submission. Server-side validation (§9.2) is the authoritative gate. When physical_locations_count > 0, free-audit appends " in {location}" to every audited keyword (worker/jobs.py _localize), producing local-intent queries like "iv drip in Seattle". A count of 0 yields a national/generic audit.

§ 09 — Submission WP

Audit Submission Endpoint #

New file inc/audit-handler.php registers POST /wp-json/demandnow/v1/audit with permission_callback => __return_true (auth is handled by nonce check inside the handler, step 1). A 10-step pipeline validates, dedupes, creates the lead, and fires the verify email.

9.1Request shape

JSON body fields
name, email, company, website, revenue, message, physical_locations_count, location, intent, company_url (honeypot)
Required header
X-WP-Nonce: {wp_rest nonce} — the same nonce localized in §8.4.

9.2Processing pipeline

  1. Nonce check
    wp_verify_nonce(header, 'wp_rest') — fail → 403 rest_forbidden.
  2. Honeypot check
    company_url non-empty → return 200 {ok:true, message:<generic>}. No lead is created. The response includes a message field — this intentionally differs from /contact's message-less honeypot reply, so the JS success variant renders correct copy.
  3. Rate limit
    Transient key demandnow_audit_{md5(ip)}distinct from /contact's key. Limit: 5 submissions / IP / hour. Exceeded → 429 rest_too_many_requests.
  4. Validate
    • name, email, company, website — all required. Email: sanitize_email() then is_email() → else 400 rest_invalid_email. Website: normalize (§9.3) → else 400 rest_invalid_website. Other missing required fields → 400 rest_invalid with a field-specific message.
    • physical_locations_count — coerce to integer; absent/blank → 0; must be >= 0 → else 400 rest_invalid_location.
    • locationsanitize_text_field() + trim. Required and non-empty iff physical_locations_count > 0 → else 400 rest_invalid_location. If count is 0, location is discarded (stored as ''). This mirrors free-audit's _parse_audit_fields.
  5. Normalize website
    Apply §9.3 normalization rules. Reject non-http(s), localhost, bare IPs, and obviously non-auditable hosts → 400 rest_invalid_website.
  6. Email dedupe
    See callout below — may short-circuit to a 200 re-send response without creating a new lead.
  7. Create lead
    Insert new audit_lead post with status pending. Populate all _dn_* meta including _dn_location and _dn_physical_locations_count. Set _dn_verify_token = bin2hex(random_bytes(16)) and _dn_verify_expires = now + 48h. Do not pre-create _dn_emails_sent or _dn_failure_notified — they are absent until a send succeeds (§12.6 atomic claim model).
  8. Record submission
    Increment the audit rate-limit transient counter for this IP.
  9. Send verify email
    Dispatch the verify email (§13) — on failure → 500 rest_mail_failed.
  10. Respond
    200 {ok:true, message:"Check your inbox — click the link in our email, then confirm to start your audit."}
Step 6 — Email dedupe detail

Query the most-recent audit_lead whose _dn_email meta equals the submitted email, ORDER BY post_date DESC LIMIT 1. (An equality lookup on a single meta key — wp_postmeta's default meta_key + meta_value(191) index makes this fast at this volume. Do not force the post slug.)

If that lead's status is complete or viewed within the last 30 days: skip the new audit, re-send the existing report link, and return 200 {ok:true, message:"You've run an audit recently — we've re-sent your report link to your inbox."}.

"Within 30 days" is measured by _dn_completed_at. Fallback chain if that meta is missing: _dn_viewed_at, then post_modified. The fallback must not fail open — an absent _dn_completed_at on a viewed lead is near-impossible after §12.6, but the dedupe must handle it defensively.

A prior lead with status pending, expired, or failed does not block a new submission — continue to step 7.

9.3Website normalization

Normalization steps
  1. Prepend https:// if no scheme is present.
  2. Lowercase the host.
  3. Reduce to origin + path (strip fragment, strip credentials).
Rejection criteria → 400 rest_invalid_website
Non-http(s) scheme · localhost or 127.* / ::1 · bare IP addresses · hosts that are obviously non-auditable (no TLD, internal names)

9.4Error codes

HTTP / codeMeaning / trigger
403 rest_forbiddenNonce invalid or missing.
429 rest_too_many_requestsRate limit exceeded — 5 / IP / hour.
400 rest_invalidRequired field missing or generically invalid.
400 rest_invalid_emailEmail fails sanitize_email() / is_email().
400 rest_invalid_websiteWebsite fails normalization / rejection criteria (§9.3).
400 rest_invalid_locationphysical_locations_count < 0, or count > 0 and location is empty.
500 rest_mail_failedVerify email send failed (step 9).
200 {ok:true, message:…}Honeypot triggered or email-dedupe re-send — both surface as success to the browser.
Nonce vs. LiteSpeed page cache

The homepage is LiteSpeed-cached. A wp_rest nonce baked into the cached HTML can be stale by the time the visitor submits — the endpoint returns a clean 403. The JS treats any 403 response as a "refresh and retry" signal. (/contact shares this latent issue; it is not a v1 blocker, but do not regress the 403-handling path.)

§ 12 — Endpoints WP

Verify · Progress · Webhook · Results #

Six WordPress handlers carry the visitor from a verify link to a live report — two themed pages, a status proxy, a webhook receiver, a reconciliation cron, and the shared completion routines they all funnel through. Six cross-cutting rules bind every one of them.

The shape of §12

The verify link opens /audit-verify/{token} (§12.1) — a single page that renders confirm → live progress → report by branching on _dn_status. While it runs, the browser short-polls the status proxy (§12.2) every ~5 s. Completion can arrive three ways — webhook (§12.3), poll (§12.2), or cron (§12.5) — and all three converge on one pair of idempotent routines (§12.6). The report has a durable home at /audit-report/{token} (§12.4). Every handler obeys the six rules below.

12.0Cross-cutting rules — they bind every handler

These six constraints are the canonical contract for §12. An implementation agent applies all of them to each handler that follows; the per-endpoint specs assume them and do not repeat them.

1 · Never cached
A Cache-Control header is not enough. LiteSpeed decides cacheability from its own logic, and "Cache REST API" is ON by default — a /wp-json/ GET is cacheable out of the box. Every handler — both pages and the §12.2 status endpoint — must call do_action('litespeed_control_set_nocache', …) on every response and send Cache-Control: no-store, no-cache, must-revalidate. The OPS "Do Not Cache URIs" exclusion (§16 Stage 8) is required, not belt-and-suspenders.
2 · Object cache — raw writes go stale
A raw $wpdb UPDATE on wp_postmeta does not invalidate the WordPress meta cache, and this site runs LiteSpeed's object-cache.php dropin — so a later get_post_meta() can return the pre-update value. Hard rule: every raw $wpdb write to wp_postmeta is immediately followed by wp_cache_delete($post_id,'post_meta') (+ clean_post_cache). Any handler branching on _dn_status after a raw write re-reads it cache-bypassing via $wpdb->get_var — never a stale get_post_meta() or a passed-in $lead.
3 · noindex + no referer leak
Both per-lead pages carry PII and findings. Each emits noindex,nofollow via the wp_robots filter (Yoast owns the tag — no raw <meta>) on every state, sends Referrer-Policy: no-referrer (overriding the site-wide strict-origin-when-cross-origin), and carries <meta name="referrer" content="no-referrer"> — so the URL token never rides a Referer header. Exclude both page paths from GTM/analytics page-view tags, or scrub the token from the reported path.
4 · 404 suppression
A rewrite URL has no real post. Before get_header(), the two page handlers set status_header(200) and global $wp_query; $wp_query->is_404 = false; — else WordPress renders a 404 (wrong body classes, Yoast 404 metadata). Pages render through get_header()/get_footer() so GTM and theme chrome are present.
5 · WordPress → free-audit calls
Every wp_remote_get/wp_remote_post to free-audit (§12.2, §12.4, §12.5) sets a short timeout (~8 s), redirection => 0, and requires an https dn_audit_api_url — a hung free-audit call must never pin a PHP worker.
6 · Self-hosted assets only (COEP)
inc/security.php sets a site-wide Cross-Origin-Embedder-Policy: require-corp. The progress UI and the report renderer use only theme-bundled, same-origin assets and inline SVG/CSS — no CDN, no third-party font. The report embeds zero subresources from audit.demandnow.ai — every audit visual is reconstructed client-side from JSON. Any cross-origin asset is silently blocked by COEP.
The two recurring traps

LiteSpeed caches REST GETs by default — the status endpoint will silently serve stale progress unless rule 1 is honored on every response. The object-cache dropin makes raw SQL writes invisible — branching on _dn_status right after a raw UPDATE without a cache-bypassing re-read reads the old value. Rules 1 and 2 are the source of most §12 bugs found in red-team review.


12.1—12.6The six endpoints

Each tab is one handler. Routing, request lifecycle, the traps it must defend, and — where it matters — the SQL and JSON contracts. The cross-cutting rules above apply throughout and are not repeated per tab.

12.1 · Verify & live audit page

GET + POST /audit-verify/{token}. Routing: add_rewrite_rule('^audit-verify/([^/]+)/?$', …) + a query var, dispatched on template_redirect. One handler, branching on request method.

🔒
The token is the credential — no nonce

The unguessable 128-bit verify token — bin2hex(random_bytes(16)), 32 hex chars — is the credential. A WordPress nonce would expire (~24h) before the 48h verify window and 403 legitimate clicks; for logged-out users it carries no per-victim entropy anyway. The token is retained for the life of the lead — it is the key to this page in every state. "Single-use" applies only to triggering the audit, enforced by the atomic pending→running claim — not by destroying the token.

GET — render one of the page's states

The state is chosen by _dn_status (+ expiry + token presence). The handler re-reads _dn_status cache-bypassing — a location.reload() from §12.2 lands here right after a raw _dn_status write (rule 2).

_dn_statusConditionGET renders
pending_dn_verify_expires not passedConfirm step — reassurance ("~1 min, free, watch it run here") + a <form method="post"> "Start my free audit" button. No side effects — prefetch-safe (D9).
pending_dn_verify_expires passedFlip _dn_status=expired (+ cache-purge); "Link expired — request a new audit" + CTA to the form.
running_dn_audit_token setThe live audit page: progress bar + stage label + the §12.2 poller. A reload mid-run resumes here.
runningno _dn_audit_token, _dn_claimed_at < ~90 s agoThe live audit page too — the POST winner has claimed but not yet stored the token; §12.2 returns {state:"pending"} until it lands.
runningno _dn_audit_token, _dn_claimed_at ≥ ~90 s ago"Could not start" recovery — the free-audit call crashed between claim and revert. Revert _dn_status to pending (+ cache-purge), render the confirm step with a "We couldn't start your audit — click to try again" notice. The verify token still works, so the normal POST flow re-runs cleanly.
complete / viewedThe full report inline (the shared §12.4 renderer) + a banner "Bookmark your report: /audit-report/{token}". On first render, flip complete→viewed (see prefetch note).
failedApology state ("we hit a snag — a strategist will reach out") + a "book a call" CTA.
token not found (lead hard-deleted / never issued)"This link is no longer valid — request a new audit" + CTA. Reconciles with §12.2, which returns 404 for the same case.

POST — trigger the audit (the confirm button)

  1. Re-validate the verify token
    Token exists; lead is pending; _dn_verify_expires not passed. A POST on a stale-claim lead the GET already reverted to pending is a normal retry and flows through here cleanly.
  2. Atomic claim — pending → running
    Run the conditional UPDATE below and check rows_affected === 1; then wp_cache_delete($post_id,'post_meta') and set _dn_claimed_at = now. Only the winner proceeds; a loser (double-click, second tab, replay) sees 0 and is shown the live audit page / report — never a second audit. free-audit's POST /api/audits does not dedupe — WordPress is the only guard.
  3. Winner calls free-audit POST /api/audits
    Body: {url:_dn_website, email:_dn_email, location:_dn_location, physical_locations_count:_dn_physical_locations_count, callback_ref:"lead_{post_id}", webhook_url:<this site's webhook URL>}.
  4. Branch on the response
    201 → store _dn_audit_token, _dn_audit_id, _dn_verified_at=now; render the live audit page (the verify token is not cleared). 503 (budget) → revert _dn_status to pending (+ cache-purge); "We're at capacity today — please try again later" (the same verify link still works); alert the team. Other error / timeout → revert to pending (+ cache-purge); the "could not start" recovery state; alert the team.
12.1 — atomic pending→running claim
-- Only ONE caller can flip pending → running. The winner sees -- rows_affected === 1; every loser sees 0 and is shown the live page. UPDATE wp_postmeta SET meta_value = 'running' WHERE post_id = %d AND meta_key = '_dn_status' AND meta_value = 'pending'; -- then, in PHP, immediately: -- wp_cache_delete( $post_id, 'post_meta' ); // §12 rule 2 -- update_post_meta( $post_id, '_dn_claimed_at', time() );
Prefetch / scanner safety defensive detail

The only GET side effects are the lazy pending→expired flip, the stale-claim running→pending revert, and the first-render complete→viewed flip. None is reachable by prefetching a pending lead, so the confirm step stays prefetch-safe (D9).

The complete→viewed flip could be triggered by a mail scanner / link-unfurler prefetching a complete lead's /audit-verify/ URL — so the handler skips the viewed flip when the request carries a prefetch signal (Sec-Purpose: prefetch / Purpose: prefetch). viewed is a best-effort funnel signal, not a guarantee a human opened the report.

Crash between claim and token

If the winner crashes between the claim and storing _dn_audit_token, the lead is stranded running with no token — the GET stale-claim row recovers the visitor, and the §12.5 cron alerts the team.

12.2 · Progress status endpoint

GET /wp-json/demandnow/v1/audit/status/{verifyToken} — the live audit page's browser script polls this. permission_callback => __return_true; the unguessable verify token is the credential (no nonce — §12.1).

This REST GET is cached by default

LiteSpeed's "Cache REST API" is ON out of the box, so this endpoint must call do_action('litespeed_control_set_nocache', …) itself. Cache-Control: no-store alone does not stop LiteSpeed caching it (cross-cutting rule 1).

  1. Look up the lead by _dn_verify_token
    Unknown → 404, no free-audit call (the browser treats 404 as terminal "link invalid" and stops polling). A generous, shared-NAT-sized per-IP rate limit applies — a ~1-min audit at one poll / 5 s ≈ 12–15 polls and several visitors can share an IP; cap e.g. 600 / 15 min, transient demandnow_auditpoll_{md5(ip)}. The browser treats 429 as "back off and retry", never as terminal.
  2. Terminal short-circuit
    If _dn_status is already complete/viewed/failed, answer from post meta with no free-audit call{state:"complete"|"failed", done:true}. This drops all polling load for the tail of every audit.
  3. No _dn_audit_token yet
    Return {state:"pending", percent:5, label:"Starting your audit…"} — no free-audit call.
  4. Otherwise fetch free-audit — guarded two ways
    Fetch GET {dn_audit_api_url}/api/audits/{_dn_audit_token} with X-API-Key. Short transient cache: store the raw response in dn_audit_poll_{token} for ~10 s (TTL ≥ 2× the poll interval, so a lone poller almost always hits cache; TTL is best-effort — object-cache.php may evict early). Single-flight lock: before a cache-miss fetch, wp_cache_add('dn_audit_polllock_{token}', 1, '', 15) (atomic add); if it fails another request is already fetching — return the last-known transient (or a holding running frame) rather than a parallel call. Collapses concurrent same-token misses (multiple tabs) to one fetch.
  5. On a terminal free-audit status, call §12.6
    dn_audit_apply_completion() for completed; dn_audit_apply_failure() for failed/halted_budget_exceeded/cancelled. Prime the §12.4 report transient only from a fully-completed payloadaudit.status=="completed" and summary/results present and progress.results_completed >= progress.total_pairs. If the terminal frame came from the ~10 s poll cache and might predate full result population, do one fresh uncached GET /api/audits/{token} before priming, or skip priming. Never prime the 1 h transient with a non-completed snapshot (§12.4 invariant).
  6. Respond with a trimmed, whitelisted payload
    Never raw findings, never cost/error detail — only the five fields below.
12.2 — browser-facing status frame
{ "state": "running", // pending | running | complete | failed "stage": "ranking", // scraping | extracting | ranking | finalizing | null "percent": 62, // 0–100, coarse, advisory "label": "Seeing how Gemini ranks you — 8 of 15 queries…", "done": false } // true ⇒ the script reloads the page

state / stage / percent mapping

Computed WordPress-side from §10.2's audit.status + audit.stage + progress. The mapping must be total over every (status, stage) pair free-audit can produce. Tunable without an API change.

free-audit signalstatestagepercentlabel (suggested)
status=pendingpending5"Queued — starting your audit…"
status=running, stage=NULL/unknownrunningscraping8"Starting your audit…"
status=running, stage=scrapingrunningscraping12"Reading your website…"
status=running, stage=extractingrunningextracting25"Working out your market and keywords…"
status=running, stage=ranking, total_pairs>0, results_completed < total_pairsrunningranking30 + round(55 × results_completed/total_pairs)"Seeing how Gemini ranks you — N of M queries…"
status=running, stage=ranking, total_pairs==0runningranking30"Seeing how Gemini ranks you…"
status=running, stage=ranking, results_completed ≥ total_pairs > 0runningfinalizing90"Checking site speed and crawling for SEO issues…"
status=completedcomplete100"Your report is ready." — done:true
statusfailed/halted_budget_exceeded/cancelledfailed100a fixed literal (see below) — done:true
The running + stage=NULL window is real

Per §3.2 a running + stage=NULL window genuinely occurs, and stage stays "ranking" after ranking ends — the mapping must cover both. The failed-state label is a fixed string literal (e.g. "We hit a snag finishing your audit."). The mapper never copies audit.error, the specific terminal status, or total_cost_cents into a browser field; halted_budget_exceeded maps to the same generic copy as failed, so the public endpoint cannot be used as an oracle for whether free-audit has hit its daily cap.

Browser script behavior on the live audit page client-side
  • Poll every ~5 s; render the bar + label.
  • Tween the bar client-side toward each new percent so the ~12 server frames over a ~1-min run read as a smooth advance, not ~12 visible jumps.
  • On done:true, reload the page cache-bypassing — a cache-busting query param, so the post-completion reload is never served from bfcache as the stale running view.
  • Stop polling on done and on 404.
  • Safety cap: after ~5 min with no terminal state (a ~1-min audit running 5×+ over is clearly stuck), stop polling and show "This is taking longer than usual — we'll email your report the moment it's ready" with the durable link. The §12.3 webhook and §12.5 cron still complete the lead independently.

12.3 · Webhook receiver

POST /wp-json/demandnow/v1/audit/webhook. permission_callback => __return_true; authentication is the HMAC. The handler holds no business logic — it verifies, finds the lead, and hands off to §12.6.

  1. Verify the signature
    Reconstruct the §10.3 base_string from the parsed JSON params — audit_id via strict integer cast, absent fields → "". Strip a leading sha256= (case-insensitive) from X-DN-Signature. hash_equals(computed_hex, received_hex). Mismatch → 401, log.
  2. Freshness window
    Reject (401, log) if finished_at is more than ~1 hour old.
  3. Find the lead
    By _dn_audit_token (= payload token); else fall back to callback_ref (lead_{id}) — safe because callback_ref is signed (§10.3). Not found → 200 (ack, log).
  4. Apply via the shared §12.6 routine
    audit.completeddn_audit_apply_completion(); audit.faileddn_audit_apply_failure(). §12.6 owns the callback_ref backfill, the failed → complete supersession, the never-downgrade rule, and the idempotent email guards — the webhook handler holds none of that logic.
  5. Always 200 {ok:true} once authenticated
    So free-audit stops retrying. A duplicate webhook for an already-terminal lead resolves to a logged no-op inside §12.6.
Why there is no nonce store

Replay of a fresh, validly-signed webhook is harmless — §12.6 is idempotent. The freshness window + §12.6 idempotency is the complete replay defense; a nonce store is deliberately omitted, not a gap.

12.4 · Report page

GET /audit-report/{token} — the durable, emailable, return-to-it copy of the report. Keyed by the free-audit token (which never expires), independent of the verify token. Routing: add_rewrite_rule('^audit-report/([^/]+)/?$', …).

The 1-hour caching invariant

The 1 h transient dn_audit_report_{token} may only ever hold a fully-completed report. Therefore: fetch first, then inspect audit.status — only audit.status=="completed" (with summary/results present) is written to the 1 h transient and to _dn_report_cache meta (written once). A non-completed response is not cached for 1 h — a few seconds at most.

  1. Look up the lead by _dn_audit_token
    Unknown → themed 404.
  2. Fetch the audit
    GET {api_url}/api/audits/{token} with X-API-Key (short timeout, redirection => 0 — rule 5). Inspect audit.status per the invariant above. On a fetch timeout/error, fall back to _dn_report_cache; if that too is empty, render "still generating" + auto-refresh — never block the page on a hung free-audit.
  3. Non-terminal status
    "Still generating" + light auto-refresh (rare here — a durable link is normally opened post-completion).
  4. completed → render the hybrid report
    Render §13.4–13.5 via the shared report renderer, which consumes a whitelisted projection of the §10.2 JSON — no total_cost_cents, no audit.error (§10.2 invariant). The same renderer serves the §12.1 complete branch. On first successful render, backfill _dn_completed_at if somehow empty (§12.6), then set status viewed, _dn_viewed_at=now.
  5. failed/halted/cancelled
    Apology state + "book a call" CTA — generic copy, no audit.error.
Cross-cutting rules apply verbatim

noindex, the Referrer-Policy, the COEP self-hosted-asset rule, and the no-cache recipe are all handled by the §12 cross-cutting rules — applied here without restatement.

12.5 · Reconciliation — real cron, not WP-Cron

A scheduled job every ~5 min — the authoritative backstop for dropped webhooks, closed live pages, and the reconcile_orphans worker-restart path.

WP-Cron alone is insufficient

WP-Cron fires only on traffic and is throttled by full-page caching. Cron mechanism (pinned): a registered action driven by wp cron event run --due-now over SSH from the Hostinger hPanel system cron — not an HTTP hit on wp-cron.php (which LiteSpeed can itself serve from cache, silently skipping the run). wp-config.php is not edited, so DISABLE_WP_CRON is not used — the system cron supplements the unreliable traffic-driven WP-Cron. The system cron is mandatory.

What the job does each run

Poll stalled, tokened leads
Finds audit_leads in running for >5 min with a _dn_audit_token (5×+ a normal ~1-min audit), polls GET /api/audits/{token}, and on a terminal status calls the same §12.6 routinedn_audit_apply_completion / dn_audit_apply_failure.
Alert on claimed-but-call-failed
Finds leads in running for >5 min without a _dn_audit_token (the §12.1 claim-then-crash case) — cannot be polled; alert the team.
Re-drive email delivery
Any lead complete with _dn_emails_sent ≠ '1' (or failed with _dn_failure_notified ≠ '1') older than ~10 min → re-invoke the §12.6 email dispatch (idempotent, lock-guarded). Recovers a send claimed but crashed mid-wp_mail().
Hard-ceiling alert
Any lead still running past a hard ceiling (e.g. >30 min), regardless of token → alert the team for manual triage.
Cron liveness heartbeat
Writes a dn_audit_cron_heartbeat option each run; a stale heartbeat surfaces an admin-dashboard notice, so a silently-dead Hostinger cron is noticed.

12.6 · The shared completion & failure routines

Lead completion is reachable from three independent channels — the webhook (§12.3), the live progress poll (§12.2), and the reconciliation cron (§12.5). All three call one of two shared functions. This subsection is the canonical home of the idempotency story.

Idempotency rests on three mechanisms — not on pre-seeded meta

v0.4 pre-created a '0' flag and gated on UPDATE … WHERE meta_value='0'. That was unreliable: a conditional UPDATE on wp_postmeta is atomic only when the row already exists, and leads predating the feature, partial-creation crashes, and callback_ref-backfilled leads all lack the row — the UPDATE matches nothing and nobody sends. v0.5 replaced it with the three mechanisms below.

Atomic single-lead lock
wp_postmeta has no unique (post_id, meta_key) constraint — so the lock lives in wp_options.option_name, which does have a DB-enforced unique index. dn_audit_try_lock($name) runs a raw INSERT and returns true iff a row was inserted (a duplicate-key failure ⇒ false) — a genuine atomic test-and-set, no seeded row needed. dn_audit_release_lock($name) deletes it.
Conditional status UPDATEs
Status transitions are conditional single-statement UPDATEs on the existing _dn_status row, each followed by wp_cache_delete($post_id,'post_meta') (rule 2).
Send-after-success markers
_dn_emails_sent / _dn_failure_notified are set to '1' only after wp_mail() succeeds — "done" markers the §12.5 cron reads to re-drive a crashed send, not concurrency primitives.
12.6 — dn_audit_try_lock: atomic test-and-set
-- Returns true IFF a row was inserted. A duplicate-key failure -- (the unique index on option_name) ⇒ false. No seeded row needed. INSERT INTO wp_options (option_name, option_value, autoload) VALUES (%s, '1', 'no');

dn_audit_apply_completion($lead, $payload)

  1. Backfill
    If matched via callback_ref and missing _dn_audit_token / _dn_audit_id, persist them from the payload (else the report link 404s). After backfill, the report path sanity-checks audit.callback_ref == "lead_{post_id}" and 404+alerts on mismatch.
  2. Status transition — timestamp before status
    Write _dn_completed_at first (if unset), then the conditional UPDATE _dn_status … WHERE meta_value IN ('running','failed') → 'complete' (idempotent; covers the §6 failed→complete supersession; never touches complete/viewed); wp_cache_delete after. Timestamp-before-status guarantees any reader that sees complete also sees _dn_completed_at — closing the §9.2 dedupe hole.
  3. Email dispatch — deferred, not inline
    Never send SMTP synchronously inside a webhook or a 5 s browser poll. Schedule dn_audit_dispatch_emails($post_id,'complete') via wp_schedule_single_event, driven promptly by the §12.5 system cron.
The dispatch job — at-least-once under a single-flight lock

The deferred dn_audit_dispatch_emails job: (a) dn_audit_try_lock("dn_audit_emaillock_{post_id}") — if false, another run holds it → return; (b) re-read _dn_emails_sent cache-bypassing — if '1', release lock, return; (c) send the results email + the team notification; (d) on success update_post_meta(_dn_emails_sent,'1'), on failure leave it unset; (e) finally release the lock. A crash mid-send leaves the lock released and the marker unset → the §12.5 cron re-drives it. This is at-least-once: the common concurrent case sends exactly once; a crash-during-send re-sends rather than never-sends — the correct trade for funnel-critical mail.

dn_audit_apply_failure($lead, $payload)

  1. Status transition — never downgrade
    Conditional UPDATE _dn_status … WHERE meta_value='running' → 'failed' + wp_cache_delete. Never downgrades complete/viewed/failed — a stale audit.failed for those produces rows_affected=0, a logged no-op (§6).
  2. Email dispatch — deferred and cross-gated
    Schedule dn_audit_dispatch_emails($post_id,'failed'). Before sending, the dispatch job re-reads _dn_status cache-bypassing and aborts if the lead is complete/viewed — the cross-gate that stops a lead receiving both a soft-failure email and a results email when a late audit.completed supersedes a failed (the §6 worker-restart re-queue case). The deferral (a few minutes, via the cron) gives a fast supersession time to win. Same lock/marker mechanics, on _dn_failure_notified.
completion email
Deferred; sent at-least-once — never zero in steady state, thanks to the §12.5 cron re-drive.
failure email
Deferred and cross-gated on _dn_status; a superseded failure email is suppressed.
terminal status
Correct for every interleaving of the three channels — the conditional UPDATEs settle on the right value regardless of arrival order.
Why every interleaving is safe

The status transitions, the dn_audit_try_lock test-and-set, and the done-markers are independent atomic operations, and the failure dispatch is cross-gated on _dn_status. So for any interleaving of webhook, poll, and cron: the lead ends in the correct terminal status; the completion emails send at least once; a superseded failure email is suppressed.

§ 13 — Email WP OPS

Email & Deliverability #

Four transactional emails drive the visitor funnel. The SMTP layer is an [OPS] prerequisite — not theme code — and must be configured by hand before each go-live.

13.1The four emails

Email To Trigger Content
Verify Lead Form submit → status pending "Confirm to start your free AI visibility audit" + verify link → the live audit page. ~1 min, free, watch it run. States the 48 h expiry.
Results ready Lead Completion — deferred dispatch (§12.6) "Your AI visibility report is ready" + durable /audit-report/{token} + headline score teaser. Sent even if the lead already read the report on the live page — it is their permanent copy.
Team notification dn_audit_team_recipient Completion — deferred dispatch (§12.6) Lead's full data (incl. location) + headline findings + report link.
Soft-failure Lead + team Failure — deferred, status-cross-gated dispatch (§12.6) Lead: "We hit a snag — a strategist will reach out personally." No alarming detail. Suppressed if a late audit.completed supersedes the failure (§12.6 cross-gate). Team notice goes to dn_audit_team_recipient with _dn_audit_id.

13.2Transactional email (D6) — OPS prerequisite

SMTP plugin is not theme code

Plugins live in wp-content/plugins/, outside the rsynced theme directory — the SMTP plugin is not carried by the D10 bundled theme cutover. It must be installed and configured by hand (or WP-CLI) on staging and on production separately, before the respective go-live. If the plugin is absent, wp_mail() silently reverts to Hostinger mail() and verify mail spam-folders. Stages 8 and 9 checklists call this out explicitly.

Staging clone caveat: a Hostinger Staging clone copies the prod DB, so a clone taken after prod is configured would inherit prod's SMTP credentials and send real mail from staging. Re-configure (or disable) the SMTP plugin on staging — use a separate Brevo subaccount or a mail-trap.

Provider — Brevo (Q7, resolved 2026-05-18)
Free tier 300 emails/day — above v1 volume. Native FluentSMTP connector. DKIM enforced on every send. Hostinger Email / Titan rejected — mailbox hosting, not a transactional relay; shared-IP reputation, ~100/day script-send limit, documented spam-foldering.
Fallback
Resend is the noted fallback if Brevo's deliverability disappoints. All wp_mail() output — audit emails and existing /contact emails — routes through the provider. Theme code only ever calls wp_mail().

Plugin configuration

Force From
ON — providers reject mismatched From; From address = hello@demandnow.ai (Q3)
Force Reply-To
OFF — otherwise the existing /contact email's Reply-To: {lead} is overwritten and sales replies go to the site mailbox instead of the lead
HTML + plain text
Branded inline-CSS HTML; add text/plain alternative via phpmailer_init hook ($phpmailer->AltBody). Helper: dn_audit_email_wrap($title, $bodyHtml)
Pre-existing customization
Audit the site's Code Snippets (CLAUDE.md §6) for any existing SMTP / phpmailer_init / wp_mail hooks before installing the plugin — avoid two layers fighting over transport

DNS

DKIM
Add Brevo's DKIM TXT record for demandnow.ai; verify the domain inside Brevo so the signature is valid. Brevo manages SPF on its own sending envelope.
DMARC
Add an optional DMARC record for demandnow.ai.

13.3Verify-link expiry

48-hour window

The verify link expires after 48 hours (_dn_verify_expires). The expiry is stated in the verify email so the lead is not surprised.

13.4Report layout (hybrid — D5)

The shared report renderer (§12.4) is used both inline on the live audit page's complete branch and at the durable /audit-report/{token}. It consumes a whitelisted projection of the §10.2 JSON — total_cost_cents and audit.error are dropped at ingestion; only the §13.1 team-email builder ever reads them. Three diagnostic sections, all shown free:

① AI Search Visibility
Hero score (§13.5) + Gemini engine card (mention rate + avg rank) + keyword table: each query (location-localized where applicable, §8.7), whether you appear, your rank, and who ranks #1 instead of you (top10[0].brand). Layout must not hard-assume a single engine — v2 adds ChatGPT.
② Site Health
PSI gauges (performance / accessibility / best-practices / SEO) + Core Web Vitals (LCP, CLS, INP, FCP, TTFB) with good / needs-work / poor banding.
③ SEO Crawl
crawl highlights rendered as an issue checklist.
Localization note

If audit.physical_locations_count > 0, the AI Search Visibility section notes the audit was localized: "rankings for searches in {location}".

Gated CTA — "Your fix roadmap"

"This report shows where you're losing AI visibility. On a free 30-minute strategy call we'll show you how to fix it, in priority order." Button opens #leadModal with intent=call.

13.5The AI Visibility Score

🔒
Formula — owner-approved 2026-05-18 (Q5)

Score = total_mentioned / total_pairs × 100, rounded.
total_pairs = total_keywords × len(engines) — v1: len = 1 (Gemini only).
Companion stat: average rank when mentioned (EngineSummary.avg_rank).
Computed in the WordPress renderer only when audit.status == "completed".
Formula and bands ship as written; tunable later without an API change.

Visibility bands

0 – 10
Invisible — no meaningful AI presence detected.
11 – 30
Barely visible — sporadic mentions, easily displaced.
31 – 60
Emerging — partial presence; clear wins available.
61 – 100
Competitive — strong AI visibility across tracked keywords.
Part IV · FA

The free-audit Build

Purely additive work in the Python audit engine — the JSON API, the webhook dispatcher, the schema migration, and the engine scope. The existing operator dashboard is untouched.

§ 10 — API contracts FA

free-audit API Contracts #

A new web/api.py router, mounted by web/main.py. Three endpoints. All three require X-API-Key — no session, no CORS, existing routes untouched.

Fail-closed key check

Every /api/* request compares X-API-Key against INTEGRATION_API_KEY using a constant-time comparison (hmac.compare_digest). If INTEGRATION_API_KEY is empty or unset, every request fails with 503 — an empty key must never authenticate.

10.1POST /api/audits — create & enqueue

Request
POST /api/audits X-API-Key: <INTEGRATION_API_KEY> Content-Type: application/json { "url": "https://acme.com", "email": "jane@acme.com", "callback_ref": "lead_8842", "webhook_url": "https://demandnow.ai/wp-json/demandnow/v1/audit/webhook", "physical_locations_count": 0, "location": null }

Behavior — 4-step sequence

  1. Re-validate url + location inputs
    Reuse _parse_audit_fields: HttpUrl parse; physical_locations_count >= 0; > 0 ⇒ non-empty location; == 0location forced to NULL. Add defense-in-depth rejection of localhost, private/loopback IPs, and non-public hosts. Do not trust the caller's normalization.
  2. Validate webhook_url
    Must be https. Parse with urllib.parse.urlsplit; require exact host equality: hostname.lower()WEBHOOK_URL_ALLOWLIST, or hostname == "." + entry for an explicit allowed parent. No substring / in / bare endswith matching.
    These must all FAIL

    xdemandnow.ai — allowlist entry is a suffix of the hostname, not an exact match.
    demandnow.ai.evil.com — allowlist entry appears as a substring.
    https://demandnow.ai@evil.comhostname is evil.com; the demandnow.ai is userinfo, not the host.

  3. Pre-flight budget check
    Read today's DailySpend (reuse the routes.py _today_spend_cents pattern). If total_cents >= DAILY_COST_CAP_USD * 100, return 503 before creating any rows.
  4. Single-transaction write + enqueue
    session.add(Audit(status=pending, callback_ref, webhook_url, …))await session.flush() (load-bearing: obtains audit.id before the token row; mirrors POST /start/{token} in web/routes.py) → session.add(AuditAccessToken(token=_new_token(), email, used_at=now(), audit_id=audit.id))commit(). Token is "born consumed" — not a /start/{token} link. Then enqueue_job("run_audit", audit.id).
Response 201
{ "audit_id": 123, "token": "xJ3k7Q…", "status": "pending" }
CodeCondition
401Bad or missing X-API-Key
503INTEGRATION_API_KEY empty on server
400Bad url, webhook_url, or location inputs
503Daily budget cap exceeded
Budget note

The 503 is coarse — it cannot see in-flight audits, and a check at 23:59 UTC reads a different DailySpend row than the worker at 00:01 UTC. Most over-budget audits still halt mid-run and surface as an audit.failed webhook. The worker's _check_budget is the real enforcement.

10.2GET /api/audits/{token} — results & live progress

Called server-to-server by both the WordPress report page (§12.4) and the WordPress progress endpoint (§12.2). Must answer both "is it done, render it" and "how far along is it."

Response 200 shape
{ "audit": { "id": 123, "url": "https://acme.com", "brand_detected": "Acme", "category_detected": "project management software", "status": "running", "stage": "ranking", "location": "Seattle", "physical_locations_count": 3, "started_at": "2026-05-17T17:59:02Z", "finished_at": null }, "progress": { "stage": "ranking", "keywords_total": 15, "results_completed": 8, "total_pairs": 15 }, "keywords": ["project management tool in Seattle", "…"], "engines": ["gemini"], "summary": { "total_keywords": 15, "engines": [{ "engine": "gemini", "total": 15, "mentioned": 4, "errors": 0, "avg_rank": 6.25, "mention_rate": 0.27 }] }, "results": [{ "keyword": "project management tool in Seattle", "engine": "gemini", "mentioned": true, "rank": 4, "top_brand": "Asana", "context": "…", "top10": [ {"rank": 1, "brand": "Asana"}, {"rank": 2, "brand": "Notion"} ] }], "psi": { "strategy": "mobile", "performance_score": 72.0, "accessibility_score": 95.0, "best_practices_score": 88.0, "seo_score": 91.0, "lcp": 3200.0, "cls": 0.04, "inp": 180.0, "fcp": 1800.0, "ttfb": 600.0, "error": null }, "crawl": { "pages_total": 15, "pages_ok": 14, "errors_4xx": 1, "…": "see CrawlHighlights" } }

Live-progress contract — audit.stage + progress block

FieldSourceNotes
audit.stageAudit.stage column verbatim"scraping" · "extracting" · "ranking" · null. Plain string, no transformation.
keywords_totalCOUNT of AuditKeyword rows0 until extraction commits its batch.
results_completedCOUNT of AuditResult rowsClimbs one-per-pair during ranking (§3.2). Includes errored pairs — see below.
total_pairskeywords_total × len(ENGINES)Computed from the real ENGINES tuple. Never a hard-coded engine count.
Serialization gotchas

PSI numerics: AuditPSI columns are SQLAlchemy Numeric → Python Decimal — not JSON-serializable. Cast every PSI numeric to float (or null). There is no reports.py PSI helper — build psi directly from the AuditPSI row in web/api.py.

Datetimes: emit as YYYY-MM-DDTHH:MM:SSZ, second precision. finished_at is null until the audit is terminal.

top10: AuditResult.full_top10 is a comma-joined "<rank>. <brand>" string — parse it into [{"rank": int, "brand": str}, …]. Omit the raw string.

keywords: use AuditKeyword rows (the stable planned set, already localized with " in {location}"), not distinct(AuditResult.keyword).

Non-terminal audits: return 200 with summary / results / psi / crawl empty or omitted; populate audit.status (pending | running), audit.stage, and progress. Those fields are only meaningful when audit.status == "completed".

total_cost_cents and raw audit.error are team-only. They must never reach a browser-rendered surface. The WordPress report renderer (§12.4) and the §12.2 mapper both consume a whitelisted projection that drops these fields at ingestion. Only the team-email builder (§13.1) reads them from the un-projected JSON.

results_completed counts every attempted pair, including errored ones. _rank_step writes one AuditResult row per pair regardless of success. results_completed == total_pairs means "all pairs attempted," not "all succeeded." It is a progress signal only.

CodeCondition
401Bad or missing X-API-Key
404Unknown token, or a token whose audit_id is NULL (unused operator /tokens token — no audit to report)

10.3Webhook — free-audit → WordPress on completion

free-audit POSTs to the per-audit webhook_url (falling back to env RESULT_WEBHOOK_URL if absent). Required even though the live page polls — it completes the lead and sends the email when the visitor has closed the tab, and it is the prompt team notification.

Webhook POST shape
POST <webhook_url> Content-Type: application/json X-DN-Signature: sha256=<hex HMAC-SHA256(base_string, WEBHOOK_SECRET)> { "event": "audit.completed", // | "audit.failed" "token": "xJ3k7Q…", "audit_id": 123, "status": "completed", "callback_ref": "lead_8842", "finished_at": "2026-05-17T18:04:11Z" }

Canonical serialization — both repos must produce byte-identical values

FieldTypeStringification (payload and base string)
eventstras-is: audit.completed | audit.failed
tokenstras-is
audit_idintstr(int(audit_id)) — strict integer, never a float
statusstrthe AuditStatus enum value
callback_refstras-is; empty string "" if absent
finished_atdatetimeUTC, second precision, Z suffix — strftime('%Y-%m-%dT%H:%M:%SZ'). Never null for a terminal audit.

HMAC base string

base_string construction — fixed order, newline-joined
base_string = event + "\n" + token + "\n" + audit_id + "\n" + status + "\n" + callback_ref + "\n" + finished_at
Why not raw-body HMAC?

A WordPress REST callback consumes php://input before the handler runs, making raw-body HMAC unverifiable on the WP side. Both repos build the same canonical string instead. callback_ref is included so the secondary lookup key is authenticated.

finished_at must come from a fresh SELECT

_set_terminal writes func.now() — a SQL expression, not a resolved timestamp. With expire_on_commit=False, that unresolved object stays in memory. The dispatcher must re-SELECT the audit row in a fresh session after the terminal commit and read the DB-resolved finished_at. Never serialize a SQL expression.

Event mapping

Audit terminal statusWebhook event
completedaudit.completed
failed · halted_budget_exceeded · cancelledaudit.failed

Dispatch rules — core/webhook.py

  1. Single dispatch point
    Refactor run_audit: collapse its five return statements into one result dict, return result once. Place the dispatch block after the inner try/except ladder, still inside the outer try, before finally: redis.aclose(). Skip dispatch when result["status"] == "missing".
  2. Re-validate the effective URL
    Re-check webhook_url (or the RESULT_WEBHOOK_URL fallback) against WEBHOOK_URL_ALLOWLIST immediately before POSTing — the env fallback is otherwise unchecked.
  3. Re-SELECT the audit row
    Open a fresh session; re-SELECT the audit. If its status is non-terminal, skip dispatch. Read finished_at here (avoids the SQL-expression trap above).
  4. Token lookup
    Audit has no ORM relationship to AuditAccessToken. Use: select(AuditAccessToken).where(audit_id == …).order_by(id).limit(1). If no token row exists, skip the webhook — that audit was operator-initiated.
  5. POST with httpx + tenacity
    Build a fresh httpx.AsyncClient(follow_redirects=False)follow_redirects=False prevents an allowlisted host redirecting to an internal IP (classic SSRF pivot). Retry ≈3 times with backoff via tenacity (both are already in pyproject.toml). A permanently failing webhook is logged, never raised.
Known gap — reconcile_orphans

worker/main.py reconcile_orphans flips running → failed on worker restart without invoking run_audit, so it emits no webhook. If arq re-queues the original job, that audit can later complete and dispatch audit.completed after the lead was marked failed. The failed → complete supersession rule (§6) and the WordPress reconciliation cron (§12.5) together handle this case.

§ 11 — Data & config FA WP OPS

Data & Configuration #

One Alembic migration adds two columns and a unique index. Secrets live in wp_options on the WordPress side and in env vars on the free-audit side — never in code or config files.

11.1Schema migration & worker startup

Migration summary

Revision 0008_audit_callback_ref · down_revision="0007_merge_heads" (the current single head — do not reuse the branched 0004_* numbers).

ChangeDetail
audits.callback_ref VARCHAR(255) NULL — new column
audits.webhook_url VARCHAR(2048) NULL — new column
UNIQUE index on
audit_access_tokens.audit_id
NULLs are distinct in Postgres, so unused operator tokens (audit_id IS NULL) are unaffected. Migration must pre-flight for existing duplicate non-null audit_id values (GROUP BY audit_id HAVING count(*) > 1) and fail loudly with a clear message if any exist — don't let the index creation abort web boot opaquely. The VPS Postgres carries ~11 days of audit history (§3.3); duplicates are not expected, but the check is a real guard.
No location columns in 0008

audits.location and audits.physical_locations_count already exist — they were added in migration 0004_locations_and_tokens (§3.2). Do not re-add them. v1 location support is WordPress-side only.

Worker self-migration

A new on_startup wrapper in worker/main.py runs on every worker boot — in order:

  1. Config assertion
    Assert required config (§15) — fails fast on a missing GEMINI_API_KEY and other required vars before any work is queued.
  2. command.upgrade(cfg, "head")
    Idempotent and safe under Alembic's version-table locking even if web runs it concurrently. No docker-compose.yml depends_on: web change needed.
  3. reconcile_orphans
    Existing call, unchanged.
Live DB is at 0004

The first redeploy's upgrade head applies 00050008 in sequence, not just 0008. All but 0008 are pre-existing repo migrations. Alembic's path-walking and version-table lock make the multi-step upgrade safe and idempotent across concurrent web / worker startup.


11.2Config & secrets

FA New env vars — add to .env.example + core/config.py Settings

VariablePurpose
INTEGRATION_API_KEYX-API-Key secret. Empty ⇒ /api/* fails closed (503).
WEBHOOK_SECRETHMAC key for X-DN-Signature.
WEBHOOK_URL_ALLOWLISTComma-separated exact host allowlist for webhook_url validation.
RESULT_WEBHOOK_URLFallback webhook URL when a request omits webhook_url. Re-validated against the allowlist before use. Empty ⇒ no fallback.

WP wp_options secrets — seeded per environment

Option keyStaging valueProduction value
dn_audit_api_urlhttps://audit.demandnow.ai (reachable after Stage 0)
dn_audit_api_keygenerated, staging-uniquegenerated, prod-unique (re-seed if staging was cloned after prod was seeded)
dn_audit_webhook_secretgenerated, staging-uniquegenerated, prod-unique
dn_audit_team_recipientandriy.dev005@gmail.comreal sales inbox — seeded at Stage 9 (§16)
Secrets handling rules — no exceptions

Store in wp_options with autoload => 'no'. Never in wp-config.php, never in git.

Do not pass to register_setting(), wp_localize_script(), a REST response, or any admin-screen HTML. dn_audit_api_key creates paid audits and reads every audit's data. A Stage 8/9 check confirms GET /wp-json/wp/v2/settings contains no dn_audit_* key, and greps Code Snippets (CLAUDE.md §6) for any dn_audit / get_option leak.

Accepted risk: mysqldump backups contain them — rotate if a backup is shared.

dn_audit_opt() helper + email-only fallback

The helper dn_audit_opt($key) wraps get_option. If a required option is unset, the audit form falls back to today's email-only behavior and logs a warning. This fallback does not cover the SMTP plugin being absent (see §13.2). The §12.2 status endpoint and the §12.4 report page both reuse dn_audit_api_url + dn_audit_api_key — no extra options.

§ 15 — Engine scope FA

Engine Scope — Gemini-only #

No engine change in v1. One config assertion added. ChatGPT deferred to v2.

No free-audit engine change
core/config.py:6 stays ENGINES = ("gemini",). The OpenAI ranker stays disabled — re-enabling it pulls in OpenAI's paid web_search tool at ~45¢/audit (D8).
ChatGPT → v2
A second engine (ChatGPT) is explicitly deferred to v2 (§17). Nothing in v1 prepares for it beyond the len(ENGINES) expression in the progress contract (§10.2).
Boot assertion required — blank GEMINI_API_KEY fails silently

A blank GEMINI_API_KEY does not raise at client construction — gemini_client() is @lru_cache'd and constructs without credentials. The failure happens per-keyword, deep inside the worker, and produces an all-errored "completed" audit with no obvious cause.

Extend core/config.py assert_production_safe() (or add assert_worker_safe()) to fail fast on a missing GEMINI_API_KEY. This assertion must run in the worker on_startup wrapper (§11.1), mirroring web/main.py's existing call. Today assert_production_safe() checks only APP_PASSWORD / SESSION_SECRET (§3.2) — this extends it.

Part V

Operations & Delivery

How cost and abuse are contained, the ten-stage rollout, and the test plan that gates each stage.

§ 14 — Cost & abuse WP FA

Cost & Abuse Controls #

Eight layered controls — from email verification to poll containment — keep costs bounded and the form non-abusable at v1 scale.

  1. Verified email (D2)
    No audit runs until the lead confirms via the emailed verify link. The most important gate — a real inbox is required.
  2. Confirm-button POST (D9)
    Spend is behind a deliberate POST. A GET (as emailed links are) is side-effect-free — mail scanners prefetching the link cannot trigger a paid audit.
  3. Atomic claim (§12.1)
    The audit-start sequence uses an atomic DB claim. Double-click or replay cannot create a second paid audit for the same lead.
  4. IP rate limit — 5 / IP / hour
    Applied on the submission endpoint, own bucket (§9.2). Keyed on md5(ip). See threat-model note below.
  5. Email dedupe — 1 audit / email / 30 days
    Repeat submits from the same address within 30 days return the existing report (§9.2) — no new audit, no new spend.
  6. Free-audit daily cap (DAILY_COST_CAP_USD)
    POST /api/audits returns 503 once the cap is reached (coarse gate, §10.1). The worker halt is the real enforcement. Launch value: $20 ≈ 130 Gemini audits/day (Q4).
  7. Verify-link expiry (48 h) + honeypot
    Links expire after 48 hours (_dn_verify_expires). The form includes a honeypot field (company_url); submissions that fill it create no lead and run no audit.
  8. Progress-poll containment (§12.2)
    The status endpoint is keyed by the unguessable 128-bit verify token. It short-transient-caches the free-audit response (~10 s) and uses a single-flight lock so concurrent same-token polls collapse to one upstream call — a fast or multi-tab poller cannot hammer free-audit. Polling only reads an already-running audit; it never starts one or spends money.
Threat-model honesty

The per-IP limits (5/hr submit, 600/15 min poll) are keyed on md5(ip) and are speed-bumps, not boundaries — an attacker rotating IPs defeats them. The real cost gates are D2 (verified email), the §12.1 atomic claim, the email dedupe, and the free-audit daily cap.

The §12.2 endpoint returns 404 for an unknown verify token (vs. 200 for a known one) — a token-validity signal — but the 128-bit token space makes brute-force infeasible within any rate limit, so this is accepted. Do not loosen the limit or widen the cache without revisiting it.

14.1Economics & capacity

~$0.15
per audit (Gemini-only)
~20
audits / day planned (v1)
≈$3
per day steady-state
1–3
peak concurrent audits
4
free-audit hard ceiling (MAX_CONCURRENT_AUDITS_PER_WORKER)

Each audit runs ~1 min; at 20/day spread over business hours, peak overlap is realistically 1–3. A 5th simultaneous audit queues — a queued ~1-min audit still finishes quickly, so this is acceptable for v1. The §12.2 short-poll load (≈12–15 polls per audit, transient-cached + single-flighted) is adequate at this concurrency.

Watch items if volume grows

Gemini API quota
Rate limits on the Gemini API may throttle keyword-ranking parallelism before VPS resources do.
VPS CPU contention
The VPS (2 vCPU) is shared with other stacks (§3.3). Monitor CPU under concurrent audit load.
VPS disk — 81% full
§3.3 records disk at 81%. Clear logs / old containers before volume increases.
Scaling lever — no code change required

Raise MAX_CONCURRENT_AUDITS_PER_WORKER and/or KEYWORD_PARALLELISM in the worker config, or add a second worker container. No application code change needed.

§ 16 — Stages WP FA OPS

Implementation Stages #

Ten ordered stages, each independently verifiable. Stages 1–2 (FA) and 3–4 (WP) may parallelize up to the end-to-end point at Stage 6. Stages 0–8 target staging; Stage 9 is the bundled production go-live (D10).

Change strategy

Both repos — demandnow-frontend-redesign and free-audit — commit directly to main. No feature branches, no PRs. A stage that uncovers a spec gap stops and amends this document before continuing (§0 rule 3).

0 Expose free-audit via audit.demandnow.ai OPS done
DNS A record audit.demandnow.ai → 149.50.146.215; nginx :443 server block with certbot cert proxying to 127.0.0.1:8004; four §11.2 env vars (INTEGRATION_API_KEY, WEBHOOK_SECRET, WEBHOOK_URL_ALLOWLIST, RESULT_WEBHOOK_URL) added to VPS .env and stack restarted. free-audit was already deployed — this stage is exposure only, not a fresh deploy.
Definition of Done verification
  • https://audit.demandnow.ai/healthz → 200 over the new hostname + TLS.
  • All four §11.2 env vars present in the running containers.
  • Regression: operator login still works; a manual /start/{token} audit still completes (existing :3007 vhost untouched).
1 free-audit JSON API FA done
API-key dependency (empty key fails closed); POST /api/audits — URL + location re-validation, exact-host webhook_url allowlist, pre-flight 503 guard, single-txn Audit+token; GET /api/audits/{token} — §10.2 serialization incl. audit.stage + progress block; migration 0008 with duplicate pre-flight; worker self-migration + GEMINI_API_KEY assertion.
Definition of Done verification
  • curl creates an audit + token; results JSON matches §10.2 (float PSI, Z datetimes, parsed top10, stage, progress counts).
  • During a running audit, progress.results_completed climbs toward total_pairs.
  • Wrong/empty key → 401/503; webhook_url xdemandnow.ai / demandnow.ai.evil.com → 400.
  • physical_locations_count > 0 with no location → 400. Lint + tests pass.
2 Webhook dispatcher + VPS redeploy FA done
core/webhook.py: canonical base string incl. callback_ref, real re-SELECTed finished_at in Z format, single fire-once dispatch, skip-if-no-token, follow_redirects=False, effective-URL re-validation, tenacity retry. Redeploy the updated free-audit image onto the VPS — boot-time alembic upgrade head migrates live DB 0004 → 0008.
Definition of Done verification
  • On the redeployed VPS: a real audit fires exactly one signed webhook; signature + field formats verify independently.
  • failed / halted / cancelledaudit.failed; operator audits fire no webhook.
  • alembic current0008_audit_callback_ref.
3 audit_lead CPT WP done
audit_lead custom post type — admin-only caps, list-table columns (incl. Location), meta box, Yoast sitemap exclusion (§7).
Definition of Done verification
  • CPT visible to admins only.
  • A manually created lead shows all columns + a read-only detail meta box.
4 Submission endpoint + form WP done
POST /demandnow/v1/audit: normalization, location validation (§9.2.4), indexed _dn_email dedupe newest-first, verify email. Homepage form incl. §8.7 location fields + conditional-required toggle.
Definition of Done verification
  • Submitting the form creates a pending lead + verify email.
  • Lead carries _dn_location / _dn_physical_locations_count and no pre-seeded email-guard flags (_dn_emails_sent / _dn_failure_notified do not exist until a send succeeds).
  • Honeypot / rate-limit / validation per §9.4; location required only when count > 0; no "ChatGPT" promise remains.
5 Verify page + progress poll WP done
/audit-verify/{token}: GET state machine (confirm / live-audit / report / expired / failed); POST atomic pending→running claim → free-audit call → live page; verify token retained; revert-on-failure (§12.1). §12.2 status endpoint + browser progress-poll script. Rewrite rules + flush.
Definition of Done verification
  • A prefetched GET on a pending lead shows the confirm step only — no audit, no spend.
  • Clicking "Start" once → running + real audit + progress bar advancing through the stages.
  • Double-click / POST replay → exactly one paid audit.
  • 503 from free-audit → lead reverts to pending, verify link still usable.
  • Reloading mid-run resumes the live view without starting a second audit.
6 Webhook receiver + completion routines + cron WP done
Webhook receiver (§12.3) + shared §12.6 routines (dn_audit_apply_completion / _failure), wp_options atomic lock, deferred email dispatch, wp_cache_delete after raw writes, callback_ref backfill, failed→complete supersession + failure cross-gate. Reconciliation cron (§12.5, incl. email re-drive). Four emails.
Definition of Done verification
  • On completion: lead → complete, live page reloads into the report, results + team emails arrive.
  • Lead with no pre-seeded meta flags still gets its emails.
  • Forged signature → 401, lead unchanged. Replayed webhook (>1h) → 401.
  • Dropped webhook recovered by the cron.
  • Webhook + poll + cron all firing for one audit → exactly one results email + one team email.
  • A send killed mid-wp_mail is re-driven by the cron.
  • Worker-restart failed-then-completed lead ends complete — no soft-failure email sent.
7 Shared report renderer WP done
§12.4 shared renderer — hybrid report (§13.4–13.5), used by both the §12.1 complete branch and /audit-report/{token}. No-cache + is_404=false + status_header(200), wp_robots noindex, self-hosted assets only.
Definition of Done verification
  • Report renders on-brand with real data — both inline on the live page and at /audit-report/{token}.
  • Visibility score correct; localized keywords shown when physical_locations_count > 0.
  • "Book a call" opens the call modal.
  • Pages are uncacheable + noindex; status advances to viewed.
8 SMTP, cache exclusions, cron + staging E2E WP OPS done
FluentSMTP + Brevo on staging (force-From ON, force-Reply-To OFF); SPF/DKIM; AltBody. LiteSpeed "Do Not Cache URIs": /audit-verify, /audit-report, /wp-json/demandnow/v1/audit/status. hPanel system cron (every minute, wp-cron.php). Full staging E2E.
Definition of Done verification
  • Verify + results emails land in inbox (not spam) on Gmail + Outlook.
  • Replying to a /contact notice reaches the lead, not the site mailbox.
  • Per-lead pages + the status endpoint confirmed uncached.
  • Full submit → confirm → watch-live → report run passes on staging, incl. a local-business audit with localized keywords.
9 Bundled production go-live OPS pending
Theme files ride the theme's prod cutover (CLAUDE.md §8); wp rewrite flush; seed prod wp_options secrets incl. the real dn_audit_team_recipient sales inbox (Q3 — replacing the staging andriy.dev005@gmail.com); install + configure the SMTP plugin on prod separately (not theme code); point free-audit's allowlist + this site's webhook_url at prod.
Definition of Done + Rollback note verification
  • Live end-to-end audit from demandnow.ai succeeds.
Rollback caveat

A theme rollback (CLAUDE.md §8) removes the rewrite rules — already-sent verify links 404 and in-flight leads strand. The SMTP plugin persists independently of the theme. This is accepted and known.

§ 19 — Test plan WP FA

Test Plan & Acceptance #

Green-light criteria for the full integration. All 11 must pass before Stage 9 is authorized. Per-stage checks live in the Verification column of §16.

19Done when — 11 acceptance criteria

  1. Verify email delivered promptly
    A visitor submits the audit form → verify email arrives within ~1 min.
  2. Confirm step on GET — no side effects
    The verify link opens the live audit page on the confirm step. Prefetching or scanner-crawling that link triggers nothing — no audit, no spend.
  3. Live progress bar
    Clicking "Start my free audit" starts a real audit and the page shows a progress bar advancing in real time through scraping → extracting → ranking.
  4. Report renders inline — no manual action
    When the audit finishes (~1 min) the page reloads itself into an on-brand report on demandnow.ai with real Gemini AI visibility, PSI, and crawl findings.
  5. Durable report + results email
    The same report is reachable at /audit-report/{token} and via the results email, so closing the tab loses nothing.
  6. Working CTA
    The report ends in a working "book a strategy call" CTA.
  7. Team notification with full context
    The team receives a notification with the lead's data (incl. location) and headline findings.
  8. Lead state machine walks correctly
    The lead appears in wp-admin → Audit Leads and walks pending → running → complete → viewed.
  9. Call-intent path unchanged
    The intent=call path and /contact endpoint behavior/payload are unchanged. (Their mail now routes through the SMTP transport — an improvement, not a behavior change.)
  10. Localized keywords for local businesses
    A local-business submission (physical_locations_count > 0 + a city) yields a report whose keyword table is location-localized ("… in {city}").
  11. Production rollback still works
    The CLAUDE.md §8 rollback succeeds (with the Stage 9 caveat noted — stranded in-flight leads are accepted risk).

Negative & edge cases — 30 explicit tests expand to review
Honeypot filled
No lead created; response is 200 {ok:true, message} — silent discard.
Rate limit — 6th submit/hour from one IP
→ 429. Call-bookings unaffected (separate bucket).
Invalid / unauditable website
→ 400 with the correct validation message.
Location validation
physical_locations_count > 0 with empty location → 400 rest_invalid_location. Count = 0 → any submitted location is silently discarded.
Expired verify link
Link older than 48 h with lead still pendingexpired page.
Scanner prefetch of verify link
GET → confirm step only. No audit started, no money spent.
"Start my audit" double-click / POST replay
Exactly one paid audit is created, regardless of repeat POSTs.
free-audit 503 on confirm POST
Lead reverts to pending; verify link is still usable.
Live page reloaded mid-run
Progress view resumes — no second audit created.
Tab closed mid-run
Audit still completes; webhook/cron complete the lead; results email arrives; /audit-report/{token} works.
Poll observes completion before webhook
Lead ends complete with exactly one results email + one team email (atomic guards, §12.6).
Webhook + poll + cron all fire simultaneously
Exactly one of each email — no duplicates.
Forged webhook signature
→ 401; lead state unchanged.
Replayed webhook — finished_at >1h old
→ 401; replay freshness window enforced.
Webhook dropped entirely + tab closed
Reconciliation cron picks up the lead and completes it.
Worker restart mid-audit
Lead briefly failed, then a late audit.completed supersedes it → ends complete.
Crash after free-audit 201 — token not stored
Webhook found via callback_ref; token backfilled; report link works.
free-audit unreachable when report opened
Cached / _dn_report_cache render is served.
Audit fails / budget-halts
audit.failed → soft-failure state (inline + email) + team notice. Generic copy — no budget oracle exposed.
Status endpoint with bogus verify token
→ 404; no free-audit call made.
Duplicate email within 30 days
Existing (most recent) report re-sent; no new audit spend.
Reply to /contact team notification
Reply goes to the lead (Reply-To), not the site mailbox.
Required wp_options secret missing
Form falls back to email-only behavior; error is logged.
Per-lead pages opened twice
/audit-report/{token} and the live page are not served from full-page cache — PHP re-runs on every request.
Crash between atomic claim and token-store
Visitor's live page shows "could not start — try again" (not a stuck bar); re-POST cleanly starts the audit; team alerted by cron.
Post-completion reload does not loop
The raw _dn_status write is wp_cache_delete'd so the reloaded GET reads complete, not stale running.
complete lead with no pre-seeded email flags
Still receives its emails — the v0.5 wp_options atomic lock has no dependency on a seeded flag row.
Email send killed mid-wp_mail()
_dn_emails_sent stays unset; the §12.5 cron re-drives the dispatch. Lead is not left silently un-emailed.
LiteSpeed REST caching — status endpoint
§12.2 status endpoint confirmed not cached by LiteSpeed (poll responses never freeze) — verified with "Cache REST API" option on.
running + stage=NULL window
Progress bar shows "Starting your audit…" — not blank or frozen — during the post-running / pre-scraping window.
§ 17 — v2 backlog WP FA

Out of Scope · v2 Backlog #

Items deliberately deferred from v1. Nothing here blocks the current go-live. The §13.4 report layout and the §13.5 total_pairs formula are already engine-count-agnostic — adding a second engine is an additive change.

ChatGPT / OpenAI engine
Add openai to ENGINES, budget OpenAI's web_search tool (~45¢/audit vs. Gemini's ~$0.15), restore the homepage engine copy, add a second engine card to the report. §13.4 layout and §13.5 formula are already engine-count-agnostic.
CRM push
On complete, push the lead to HubSpot, Pipedrive, or similar. v1 stores leads as a WordPress CPT only — no external CRM.
SSE / websocket live progress
Replace §12.2 short-polling with a streamed feed. Needs a browser-facing free-audit auth path + CORS, or a robust PHP SSE proxy. v1 uses ~5 s polling — it survives Hostinger + LiteSpeed.
Lead-facing audit cancellation
Surface free-audit's existing cancel_requested flow as a "cancel" control on the live page. v1 has no cancel UI for the visitor.
Auto-retry on budget halt
v1 alerts the team on a budget halt and stops. v2 would queue an automatic retry after the cap resets.
PDF export
A downloadable PDF of the report. v1 report is on-page HTML only.
Lead self-service
Re-run an audit, view history, manage an account. v1 has no lead login or dashboard.
Per-client API keys
Separate INTEGRATION_API_KEY credentials per client or partner. v1 uses a single shared key.
Part VI

Reference

The file-by-file change map for implementers, and the full revision history.

§ 21 — File map WP FA

File-Change Map #

Every file touched by the integration across both repositories. Use the tabs to focus on one repo. Files marked new did not exist before this integration.

File Status Change
web/api.py new /api/* router + X-API-Key dependency; POST /api/audits (incl. location / physical_locations_count); GET /api/audits/{token} (incl. stage + progress).
core/webhook.py new Canonical-string HMAC signing + retrying httpx dispatcher (follow_redirects=False).
core/config.py modified New Settings fields (§11.2); assert_* config check incl. GEMINI_API_KEY (§15); keep ENGINES=("gemini",).
web/main.py modified Mount the /api router.
web/reports.py modified Reuse summarize / crawl_highlights / group_by_keyword; PSI + the progress counts serialized in web/api.py.
worker/jobs.py modified run_audit single-return refactor + single webhook dispatch point (§10.3). No _localize change — already correct.
worker/main.py modified New on_startup wrapper: config assertion → alembic upgrade headreconcile_orphans (§11.1, §15).
core/models.py modified callback_ref, webhook_url columns; unique index + duplicate pre-flight. Location columns already present.
migrations/versions/0008_audit_callback_ref.py new callback_ref / webhook_url migration with duplicate pre-flight. Applies on top of 0004_locations_and_tokens via the 0007_merge_heads merge point.
.env.example modified Documents the four new integration env vars (INTEGRATION_API_KEY, WEBHOOK_SECRET, WEBHOOK_URL_ALLOWLIST, RESULT_WEBHOOK_URL).
File Status Change
inc/audit-lead.php new CPT registration, admin list-table columns, meta box, status helpers, Yoast sitemap exclusion.
inc/audit-handler.php new POST /demandnow/v1/audit endpoint; verify + live-audit-page GET state machine + POST atomic claim; /audit/status progress endpoint; webhook receiver; shared §12.6 completion/failure routines; report routing; emails; free-audit HTTP client; reconciliation cron callback.
inc/audit-report-template.php new Shared report renderer (§12.4) — hybrid findings view used by the live page and /audit-report/{token}.
functions.php modified require the three new modules; add_rewrite_rule for /audit-verify/ + /audit-report/; after_switch_theme flush; auditUrl added to demandnowAjax.
front-page.php modified Audit-variant website required; §8.7 location / physical_locations_count fields; success copy; ChatGPT references removed (§8.6).
assets/js/theme.js modified Variant required toggles (website + conditional location); intent-based endpoint routing; 403-retry hint. The live-audit-page progress-poll script is enqueued for the /audit-verify/ template.
style.css modified Live audit page (progress bar) + report page styles + branded email styles.
CLAUDE.md modified Documents the new modules, REST endpoints, wp_options secrets, the SMTP plugin, and the hPanel system cron.
§ 20 — Changelog

Changelog #

Revision history of this document — 9 versions from the initial draft (v0.1) through three red-team passes (v0.2–0.5), a full open-questions resolution (v0.6), a Sprint-1 implementation pass (v0.7), staged OPS rollout (v0.8–0.9).

v0.9 2026-05-18 · Stage 8 — SMTP, cache, cron + full E2E

Stage 8 complete. FluentSMTP 2.2.95 installed on staging; Brevo wired (provider sendinblue, Force-From ON → DemandNow <hello@demandnow.ai>). Existing Code Snippets audited — none touch mail. Brevo domain demandnow.ai DKIM-authenticated (verified via Brevo API). LiteSpeed "Do Not Cache URIs" set for /audit-verify, /audit-report, and /wp-json/demandnow/v1/audit/status. hPanel system cron added (Hostinger Personalizado mode, /usr/bin/php …/staging/wp-cron.php, every minute) and verified firing via a future-dated probe event.

PSI fixed — v0.7 known issue resolved: PSI_API_KEY added to the VPS free-audit/.env (free-audit had been falling back to GEMINI_API_KEY, which PSI rejects → 403); stack recreated; subsequent audit returned a real PSI score.

Full E2E passed on staging: submit → verify email → confirm GET → start POST → free-audit audit #7 → webhook → complete → report → viewed. Verify + results + team emails delivered via Brevo (0 bounces).

[WP] fix: dn_audit_verify_url / dn_audit_report_url in audit-emails.php now append a trailing slash — eliminates the redirect_canonical 301 on every audit-email link.

Remaining: optional local-business E2E variant + Outlook deliverability spot-check; Stage 9 (bundled production go-live).

v0.8 2026-05-18 · Stage 0 — exposure (OPS) + staging config

DNS A record audit.demandnow.ai → 149.50.146.215 created (Hostinger panel); nginx :443 server block added on the VPS proxying to 127.0.0.1:8004; Let's Encrypt cert issued (valid → 2026-08-16); :80→:443 redirect. https://audit.demandnow.ai/healthz → 200 over the new hostname + TLS.

Staging dn_audit_* options seeded: dn_audit_api_url=https://audit.demandnow.ai; dn_audit_api_key / dn_audit_webhook_secret set from VPS .env and hash-verified equal; dn_audit_team_recipient=andriy.dev005@gmail.com. Connectivity verified staging→audit.demandnow.ai: API-key auth enforced (401 no-key, 404 valid-key/unknown-token). VPS WEBHOOK_URL_ALLOWLIST already lists staging.demandnow.ai,demandnow.ai.

Regression: the operator :3007 vhost and all 5 free-audit containers untouched — Stage 0 only added an independent vhost.

Still pending: Stage 8 (SMTP, LiteSpeed URI exclusions, hPanel cron), Stage 9, full E2E.

v0.7 2026-05-18 · Sprint 1 — code Stages 1–7

All code Stages 1–7 built across both repos by a delegated agent team — no spec amendment needed (§0 rule 1 held throughout).

[FA] Stages 1–2: web/api.py, core/webhook.py, run_audit single-dispatch refactor, migration 0008, worker self-migration. Stack redeployed to the VPS; live DB migrated 0004→0008 cleanly. /api/audits auth, a live audit, §10.2 JSON shape, and operator-dashboard regression all verified.

[WP] Stages 3–7: audit_lead CPT, core/email modules, submission endpoint + form, /audit-verify live page + progress poll, webhook receiver + reconciliation cron, shared report renderer. Deployed to staging; PHP-lint-clean; smoke-tested. One defect found and fixed (<title>Blog</title> on the audit pages).

Deferred: OPS Stages 0, 8, 9; seeding the WP dn_audit_* options; full E2E.

Operational finding: PSI returned HTTP 403 on the VPS — Site Health section empty until a PSI_API_KEY is configured. Fixed in v0.9.

v0.6 2026-05-18 · Open questions closed — implementation unblocked

All 9 open questions resolved; §18 emptied; §0 rule 1 satisfied — implementation agents may proceed.

VPS recon (Q1): free-audit already deployed and running on the VPS (5 containers, up 10+ days). Stage 0 cut from a full deploy to DNS + nginx + TLS only. New §3.3 records the host facts. Q2 subdomain confirmed NXDOMAIN → A record in Stage 0. Q3 From = hello@demandnow.ai, team recipient = andriy.dev005@gmail.com (staging). Q4 cost cap stays $20. Q5/Q5b score formula + bands and de-ChatGPT'd homepage copy approved. Q6 both repos → main directly. Q7 Brevo confirmed; Hostinger Email/Titan rejected. Q8 concurrency sizing resolved (~20 audits/day; peak 1–3; VPS adequate).

Audit runtime corrected ~3–5 min → ~1 min throughout (keywords ranked concurrently, KEYWORD_PARALLELISM=5, empirically verified). Coupled timeouts retuned: §12.2 browser safety cap 15→5 min; §12.5 reconcile threshold 20→5 min, hard ceiling 2 h→30 min.

Internal red-team folded in: live Alembic head at 0004_locations_and_tokens — §3.3/§11.1 record the 0004→0008 redeploy jump; free-audit code redeploy explicitly assigned to Stage 2; poll interval reconciled to ~5 s; §12.1 stale-claim window tightened 3 min → 90 s.

v0.5 2026-05-18 · Third red-team — idempotency, caching, live-page hardening

Third red-team (6 parallel agents on the v0.4 on-page design): 8 Critical / 25 Major issues folded in.

Idempotency rebuilt: v0.4's pre-created '0' email guard could silently never-send if the seeded row was missing — replaced with a wp_options unique-key atomic lock + send-after-success markers + §12.5 cron re-drive. Emails moved to deferred dispatch (off the synchronous webhook/poll path). apply_failure cross-gated on status — a failed→complete supersession never sends a soft-failure email.

Caching: every raw wp_postmeta write mandates wp_cache_delete (LiteSpeed's object-cache.php was serving stale _dn_status, looping the post-completion reload). §12.2 status endpoint must call litespeed_control_set_nocache itself — LiteSpeed REST caching is on by default, Cache-Control alone does nothing. The 1 h report transient may only hold a fully-completed report.

Live page: added the running-without-token recovery state; a full (status, stage) progress mapping covering the real running+stage=NULL window; single-flight poll lock; _dn_claimed_at.

Security: shared renderer consumes a whitelisted projection (no total_cost_cents / audit.error to the browser); generic copy on budget halt; noindex + Referrer-Policy: no-referrer on both per-lead pages. New §18 Q8 (peak concurrency/VPS sizing).

v0.4 2026-05-18 · On-page live audit (D1/D4/D11/D12)

D1/D4 revised, D11 added: the audit now runs on the verify page itself with a live progress bar; the report renders inline on completion. Results email + /audit-report/{token} retained as the durable copy. D12 added: location / physical_locations_count collected in v1 (free-audit already supports them end-to-end — no FA schema change needed).

New: the browser→WordPress progress endpoint (§12.2) short-polling GET /api/audits/{token}; audit.stage + a progress block added to that response (§10.2); shared idempotent dn_audit_apply_completion/_failure routines (§12.6) with atomic per-flag email guards, since completion is now reachable from webhook and poll and cron. §3.2 corrected to record Audit.stage, pre-existing location columns, incremental AuditResult commits, and free-audit's existing SSE live-progress UI. Verify token is no longer cleared on 201. Non-goals/§17 updated — in-page progress + location promoted into v1; SSE + cancellation deferred to v2.

v0.3 2026-05-17 · Second red-team — contracts hardened

Second red-team on the v0.2 deltas. Canonical HMAC serialization + finished_at re-SELECT + Z format; callback_ref in the signed base string; exact-host webhook_url allowlist + follow_redirects=False; run_audit single-dispatch refactor; replay freshness window; indexed _dn_email dedupe; §12.1 atomic pending→running claim; confirm-page nonce dropped; callback_ref backfill; failed→complete supersession; worker self-migration; migration duplicate pre-flight; SMTP plugin designated a separate [OPS] prerequisite. 8 open questions opened.

v0.2 2026-05-17 · First red-team — 4 new decisions, contracts rewritten

First red-team (6 Critical / ~15 Major). Added D6 (SMTP), D8 (Gemini-only), D9 (confirm-button POST, not GET), D10 (bundled prod cutover). §10 API contracts fully rewritten. Q7 resolved (verify link → front-page.php form line numbers verified).

v0.1 · v0.1.1 2026-05-17 · Initial draft

v0.1 — Initial draft from spec brainstorming. 7 decisions; 7 open questions.

v0.1.1 — Q7 resolved: front-page.php form line numbers verified against the live file.