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.
Implementation progress
Orientation
What this project does, how to read this document, and the vocabulary the rest of the spec assumes.
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.
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:
intent=call keeps today's email-only behavior, untouched.web_search tool costs ~45¢/audit → deferred to v2./start/{token} flow stay exactly as-is.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.
#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.
| Tag | Meaning | Repository / surface |
|---|---|---|
| WP | WordPress theme work | demandnow-frontend-redesign — the demandnow-redesign theme |
| FA | Audit-engine work | free-audit — the Python audit pipeline on the VPS |
| OPS | Deployment / infra | No repo change — DNS, nginx, TLS, plugins, cron |
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.
Glossary #
Terms the rest of the spec uses precisely. Skim once; refer back when a section leans on one.
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.audit_lead post.String(64) token from AuditAccessToken. One per audit; doubles as the credential to read that audit's results./audit-verify/{token}). One URL, multiple visitor states — confirm → live progress → report → expired → failed. Hosts the whole visitor-facing experience (D11).Audit.stage column: scraping · extracting · ranking. The basis for the progress bar.The Plan
The locked decisions, the verified facts about both systems today, the target architecture, and the funnel that ties it together.
Decisions #
Twelve locked decisions. These are settled — an implementation agent does not relitigate them. Each carries the rationale that closed it.
audit.demandnow.ai. Hostinger shared hosting cannot run the stack. Verified: the stack is already deployed and running on the VPS./audit-report/{token}. On-brand, retargeting intact, no PDF.mail() spam-folders.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.web_search tool ≈ 45¢/audit; Gemini-only ≈ $0.15/audit. ChatGPT → v2./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.Resolved open questions § 18 — all 9 closed, v0.6
All open questions were resolved before implementation began (§0 rule 1). For the record:
| # | Question | Resolution |
|---|---|---|
| Q1 | VPS connection / environment | SSH alias myvps → 149.50.146.215:5854. free-audit already deployed & running — Stage 0 reduces to DNS + nginx + TLS. |
| Q2 | Subdomain + DNS control | Confirmed — audit.demandnow.ai; DNS controlled at Hostinger. A record created in Stage 0. |
| Q3 | Team recipient + From mailbox | Team notice → andriy.dev005@gmail.com (staging); real sales inbox at Stage 9. From → hello@demandnow.ai. |
| Q4 | DAILY_COST_CAP_USD launch value | Keep $20 (≈130 Gemini audits/day). |
| Q5 | AI Visibility Score formula + bands | Approved as written — mentioned / total_pairs × 100; bands 0–10 / 11–30 / 31–60 / 61–100. |
| Q5b | De-ChatGPT'd homepage copy | Approved — "…where you're invisible in Google and Gemini's AI answers, and the fastest wins to fix it." |
| Q6 | Change strategy per repo | Both repos commit directly to main — no feature branches, no PRs. |
| Q7 | Transactional email provider | Brevo — free 300/day, native FluentSMTP connector, DKIM enforced. Hostinger Email/Titan rejected. |
| Q8 | Peak concurrency / VPS sizing | ~20 audits/day; ~1-min audits ⇒ peak concurrency 1–3; free-audit ceiling 4 concurrent. VPS adequate. |
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.
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.
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).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).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).functions.php → demandnow_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-196doesadd_rewrite_rule()oninitwith no flush — relies on a manualwp rewrite flush. There is noafter_switch_themehook today.- head
- GTM hardcoded in
header.php:13-28. Robots / canonical meta are owned by Yoast. - security
inc/security.php:14-15sets a site-wideCross-Origin-Embedder-Policy: require-corp.- host
- Hostinger shared hosting; no Docker;
wp-config.phpmust not be edited (CLAUDE.md §11). LiteSpeed Cache full-page-caches aggressively.
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.
web), arq worker (worker), Postgres, Redis, Playwright (browser). 5 services in docker-compose.yml. web binds 127.0.0.1:${WEB_HOST_PORT}.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).worker/jobs.py run_audit (~450–498): scraping → extracting → ranking, 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.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 / entity | Detail |
|---|---|
| Audit.stage | String(32), nullable — values scraping | extracting | ranking (migration 0002_audit_stage). Basis for the progress bar. |
| Audit.location | String(255), nullable (migration 0004_locations_and_tokens) — already exists. |
| Audit.physical_locations_count | Integer, default 0 (migration 0004) — already exists. |
| Audit.started_at | Server func.now() at row creation. |
| Audit.finished_at | Nullable; terminal-only. Also on Audit: cancel_requested, total_cost_cents, error, scraped_text, brand_detected, category_detected. |
| AuditAccessToken | token (String(64), unique, indexed), email (nullable), created_at, used_at (nullable), audit_id FK ON DELETE SET NULL, nullable. |
| PSI columns | Score / vitals are Numeric → Python Decimal. Audit has no back-relationship to AuditAccessToken; operator-run audits (POST /audits) have no token at all. |
| AuditResult.full_top10 | A comma-joined "1. Asana, 2. Notion" string (core/ranker.py _format_top10) — not JSON. |
Incremental commits — what is observable
| Data | Commit pattern | Observable as |
|---|---|---|
| AuditResult | One commit per keyword-engine pair as ranking proceeds (_rank_step) | A row count → ranking progress |
| AuditKeyword | One batch when extraction finishes | stage change only |
| AuditPSI | Single commit at end of its side-task | stage change only |
| AuditCrawlPage | Single commit at end of its side-task | stage 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 < 0 → 400; 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(PydanticBaseSettings);MAX_KEYWORDS_PER_AUDIT = 15;assert_production_safe()today checks onlyAPP_PASSWORDandSESSION_SECRET(§15 extends it).- reports
web/reports.py:summarize(),crawl_highlights(),group_by_keyword(). No PSI helper.- migrations
- A healed branch (
0004_audit_psiand0004_locations_and_tokens); the current single head is0007_merge_heads. - nginx
deploy/nginx/free-audit.confexists but is pinned to a different hostname/port and to nginx zones defined elsewhere — a template, not a drop-in.
Load-bearing traps
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.
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 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.
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.
- connection
- SSH config alias
myvps→ host149.50.146.215, port5854, userroot, 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 standalonedocker-compose). - free-audit state
- The 5-service stack (
web,worker,browser,postgres,redis) up 10–11 days.webpublished on127.0.0.1:8004;/healthzreturns{"status":"ok"}. - current URL
https://vps-4849885-x.dattaweb.com:3007— the nginxfree-auditblock listens on:3007for the VPS's own hostname. nginx 1.24 running with:80/:443free 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(arqmax_jobs). One worker container; no Docker-level horizontal scaling.
alembic current on the live web container reports head 0004_locations_and_tokens — below 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).
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.
There is no audit.demandnow.ai server block, TLS certificate, or DNS record — audit.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.
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.
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.
/start/{token} untouched.
- API-key auth dependency
POST /api/audits(incl.location/physical_locations_count)GET /api/audits/{token}— incl.audit.stage+ a liveprogressblockcore/webhook.pydispatchercallback_ref+webhook_urlcolumns + a unique index onaudit_access_tokens.audit_id- worker self-migration + boot config assertion
- redeploy the updated stack onto the already-running VPS
location columns — they already exist (§3.2).
inc/ module(s); existing /contact flow untouched.
audit_leadCPTPOST /wp-json/demandnow/v1/audit/audit-verify/{token}— GET confirm step + live audit page + report; POST triggerGET /wp-json/demandnow/v1/audit/status/{token}— browser progress pollPOST /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
locationfields) + homepage copy
- 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
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_leadCPT, 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.
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.
_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
| Status | Set when | Lead sees | Team 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
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.
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.
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
- 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
"{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).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.
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 key | Notes |
|---|---|
| _dn_name, _dn_company, _dn_revenue, _dn_message | Strings — basic lead profile fields. |
| _dn_email | Sanitized + validated with sanitize_email() / is_email(). The dedupe lookup key (§9.2). |
| _dn_website | Normalized URL (§9.3). The URL that is actually audited. |
| _dn_location | City/region string. Empty when _dn_physical_locations_count is 0 (§8.7). Discarded server-side when count is 0. |
| _dn_physical_locations_count | Non-negative integer. 0 for pure-online businesses (§8.7). Absent/blank coerced to 0. |
| _dn_status | Funnel status (§6). Written only via the atomic claim described in §12.1 / §12.6. |
| _dn_verify_token | 32 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_expires | Unix timestamp: submit time + 48 h. Gates only a still-pending lead. |
| _dn_claimed_at | Unix 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_at | Unix ts of email-link click. |
| _dn_completed_at | Unix ts. Written before _dn_status flips to complete — see gotcha below. |
| _dn_viewed_at | Unix ts set when the report is first rendered. |
| _dn_audit_token | The free-audit access token. May be backfilled from the webhook (§12.3). |
| _dn_audit_id | free-audit numeric audit id. |
| _dn_emails_sent | Completion-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_notified | Failure-email done marker. Same model as _dn_emails_sent. |
| _dn_submit_ip | Submitting IP address — used for abuse triage only. |
| _dn_report_cache | Last 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). |
_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
_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.
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.
website required for auditsapplyVariant() 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.intent field: audit → POST to the new audit endpoint (§9); call → POST to /contact, exactly as today. No change to the call flow.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).auditUrlauditUrl: 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.revenue and message are kept exactly as-is — no markup, label, or validation changes for either field.8.6Homepage copy — Gemini, not ChatGPT
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)
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, default0- 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'sinputevent viaapplyVariant() - When count = 0
- Optional; discarded server-side (stored as
'')
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.
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
name, email, company, website, revenue, message, physical_locations_count, location, intent, company_url (honeypot)
X-WP-Nonce: {wp_rest nonce} — the same nonce localized in §8.4.9.2Processing pipeline
-
Nonce check
wp_verify_nonce(header, 'wp_rest')— fail →403 rest_forbidden. -
Honeypot check
company_urlnon-empty → return200 {ok:true, message:<generic>}. No lead is created. The response includes amessagefield — this intentionally differs from/contact's message-less honeypot reply, so the JS success variant renders correct copy. -
Rate limitTransient key
demandnow_audit_{md5(ip)}— distinct from/contact's key. Limit: 5 submissions / IP / hour. Exceeded →429 rest_too_many_requests. -
Validate
name,email,company,website— all required. Email:sanitize_email()thenis_email()→ else400 rest_invalid_email. Website: normalize (§9.3) → else400 rest_invalid_website. Other missing required fields →400 rest_invalidwith a field-specific message.physical_locations_count— coerce to integer; absent/blank →0; must be>= 0→ else400 rest_invalid_location.location—sanitize_text_field()+ trim. Required and non-empty iffphysical_locations_count > 0→ else400 rest_invalid_location. If count is0,locationis discarded (stored as''). This mirrors free-audit's_parse_audit_fields.
-
Normalize websiteApply §9.3 normalization rules. Reject non-
http(s),localhost, bare IPs, and obviously non-auditable hosts →400 rest_invalid_website. -
Email dedupeSee callout below — may short-circuit to a
200re-send response without creating a new lead. -
Create leadInsert new
audit_leadpost with statuspending. Populate all_dn_*meta including_dn_locationand_dn_physical_locations_count. Set_dn_verify_token = bin2hex(random_bytes(16))and_dn_verify_expires = now + 48h. Do not pre-create_dn_emails_sentor_dn_failure_notified— they are absent until a send succeeds (§12.6 atomic claim model). -
Record submissionIncrement the audit rate-limit transient counter for this IP.
-
Send verify emailDispatch the verify email (§13) — on failure →
500 rest_mail_failed. -
Respond
200 {ok:true, message:"Check your inbox — click the link in our email, then confirm to start your audit."}
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
- Prepend
https://if no scheme is present. - Lowercase the host.
- Reduce to origin + path (strip fragment, strip credentials).
400 rest_invalid_websitehttp(s) scheme · localhost or 127.* / ::1 · bare IP addresses · hosts that are obviously non-auditable (no TLD, internal names)9.4Error codes
| HTTP / code | Meaning / trigger |
|---|---|
| 403 rest_forbidden | Nonce invalid or missing. |
| 429 rest_too_many_requests | Rate limit exceeded — 5 / IP / hour. |
| 400 rest_invalid | Required field missing or generically invalid. |
| 400 rest_invalid_email | Email fails sanitize_email() / is_email(). |
| 400 rest_invalid_website | Website fails normalization / rejection criteria (§9.3). |
| 400 rest_invalid_location | physical_locations_count < 0, or count > 0 and location is empty. |
| 500 rest_mail_failed | Verify email send failed (step 9). |
| 200 {ok:true, message:…} | Honeypot triggered or email-dedupe re-send — both surface as success to the browser. |
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.)
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 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.
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.$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.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.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.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.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.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 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_status | Condition | GET renders |
|---|---|---|
| pending | _dn_verify_expires not passed | Confirm 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 passed | Flip _dn_status=expired (+ cache-purge); "Link expired — request a new audit" + CTA to the form. |
| running | _dn_audit_token set | The live audit page: progress bar + stage label + the §12.2 poller. A reload mid-run resumes here. |
| running | no _dn_audit_token, _dn_claimed_at < ~90 s ago | The live audit page too — the POST winner has claimed but not yet stored the token; §12.2 returns {state:"pending"} until it lands. |
| running | no _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 / viewed | — | The 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). |
| failed | — | Apology 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)
- Re-validate the verify tokenToken exists; lead is
pending;_dn_verify_expiresnot passed. A POST on a stale-claim lead the GET already reverted topendingis a normal retry and flows through here cleanly. - Atomic claim —Run the conditional
pending → runningUPDATEbelow and checkrows_affected === 1; thenwp_cache_delete($post_id,'post_meta')and set_dn_claimed_at = now. Only the winner proceeds; a loser (double-click, second tab, replay) sees0and is shown the live audit page / report — never a second audit. free-audit'sPOST /api/auditsdoes not dedupe — WordPress is the only guard. - Winner calls free-auditBody:
POST /api/audits{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>}. - 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_statustopending(+ cache-purge); "We're at capacity today — please try again later" (the same verify link still works); alert the team. Other error / timeout → revert topending(+ cache-purge); the "could not start" recovery state; alert the team.
-- 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.
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).
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).
- Look up the lead byUnknown →
_dn_verify_token404, no free-audit call (the browser treats404as 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, transientdemandnow_auditpoll_{md5(ip)}. The browser treats429as "back off and retry", never as terminal. - Terminal short-circuitIf
_dn_statusis alreadycomplete/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. - NoReturn
_dn_audit_tokenyet{state:"pending", percent:5, label:"Starting your audit…"}— no free-audit call. - Otherwise fetch free-audit — guarded two waysFetch
GET {dn_audit_api_url}/api/audits/{_dn_audit_token}withX-API-Key. Short transient cache: store the raw response indn_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.phpmay 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 holdingrunningframe) rather than a parallel call. Collapses concurrent same-token misses (multiple tabs) to one fetch. - On a terminal free-audit status, call §12.6
dn_audit_apply_completion()forcompleted;dn_audit_apply_failure()forfailed/halted_budget_exceeded/cancelled. Prime the §12.4 report transient only from a fully-completed payload —audit.status=="completed"andsummary/resultspresent andprogress.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 uncachedGET /api/audits/{token}before priming, or skip priming. Never prime the 1 h transient with a non-completed snapshot (§12.4 invariant). - Respond with a trimmed, whitelisted payloadNever raw findings, never cost/error detail — only the five fields below.
{ "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 signal | state | stage | percent | label (suggested) |
|---|---|---|---|---|
status=pending | pending | — | 5 | "Queued — starting your audit…" |
status=running, stage=NULL/unknown | running | scraping | 8 | "Starting your audit…" |
status=running, stage=scraping | running | scraping | 12 | "Reading your website…" |
status=running, stage=extracting | running | extracting | 25 | "Working out your market and keywords…" |
status=running, stage=ranking, total_pairs>0, results_completed < total_pairs | running | ranking | 30 + round(55 × results_completed/total_pairs) | "Seeing how Gemini ranks you — N of M queries…" |
status=running, stage=ranking, total_pairs==0 | running | ranking | 30 | "Seeing how Gemini ranks you…" |
status=running, stage=ranking, results_completed ≥ total_pairs > 0 | running | finalizing | 90 | "Checking site speed and crawling for SEO issues…" |
status=completed | complete | — | 100 | "Your report is ready." — done:true |
status ∈ failed/halted_budget_exceeded/cancelled | failed | — | 100 | a fixed literal (see below) — done:true |
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
percentso 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
doneand on404. - 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.
- Verify the signatureReconstruct the §10.3
base_stringfrom the parsed JSON params —audit_idvia strict integer cast, absent fields →"". Strip a leadingsha256=(case-insensitive) fromX-DN-Signature.hash_equals(computed_hex, received_hex). Mismatch →401, log. - Freshness windowReject (
401, log) iffinished_atis more than ~1 hour old. - Find the leadBy
_dn_audit_token(= payloadtoken); else fall back tocallback_ref(lead_{id}) — safe becausecallback_refis signed (§10.3). Not found →200(ack, log). - Apply via the shared §12.6 routine
audit.completed→dn_audit_apply_completion();audit.failed→dn_audit_apply_failure(). §12.6 owns thecallback_refbackfill, thefailed → completesupersession, the never-downgrade rule, and the idempotent email guards — the webhook handler holds none of that logic. - AlwaysSo free-audit stops retrying. A duplicate webhook for an already-terminal lead resolves to a logged no-op inside §12.6.
200 {ok:true}once authenticated
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 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.
- Look up the lead byUnknown → themed 404.
_dn_audit_token - Fetch the audit
GET {api_url}/api/audits/{token}withX-API-Key(short timeout,redirection => 0— rule 5). Inspectaudit.statusper 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. - Non-terminal status"Still generating" + light auto-refresh (rare here — a durable link is normally opened post-completion).
- Render §13.4–13.5 via the shared report renderer, which consumes a whitelisted projection of the §10.2 JSON — no
completed→ render the hybrid reporttotal_cost_cents, noaudit.error(§10.2 invariant). The same renderer serves the §12.1completebranch. On first successful render, backfill_dn_completed_atif somehow empty (§12.6), then set statusviewed,_dn_viewed_at=now. - Apology state + "book a call" CTA — generic copy, no
failed/halted/cancelledaudit.error.
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 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
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 routine — dn_audit_apply_completion / dn_audit_apply_failure._dn_audit_token (the §12.1 claim-then-crash case) — cannot be polled; alert the team._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().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.
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.
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.UPDATEs on the existing _dn_status row, each followed by wp_cache_delete($post_id,'post_meta') (rule 2)._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.-- 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)
- BackfillIf matched via
callback_refand missing_dn_audit_token/_dn_audit_id, persist them from the payload (else the report link 404s). After backfill, the report path sanity-checksaudit.callback_ref == "lead_{post_id}"and 404+alerts on mismatch. - Status transition — timestamp before statusWrite
_dn_completed_atfirst (if unset), then the conditionalUPDATE _dn_status … WHERE meta_value IN ('running','failed') → 'complete'(idempotent; covers the §6failed→completesupersession; never touchescomplete/viewed);wp_cache_deleteafter. Timestamp-before-status guarantees any reader that seescompletealso sees_dn_completed_at— closing the §9.2 dedupe hole. - Email dispatch — deferred, not inlineNever send SMTP synchronously inside a webhook or a 5 s browser poll. Schedule
dn_audit_dispatch_emails($post_id,'complete')viawp_schedule_single_event, driven promptly by the §12.5 system cron.
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)
- Status transition — never downgradeConditional
UPDATE _dn_status … WHERE meta_value='running' → 'failed'+wp_cache_delete. Never downgradescomplete/viewed/failed— a staleaudit.failedfor those producesrows_affected=0, a logged no-op (§6). - Email dispatch — deferred and cross-gatedSchedule
dn_audit_dispatch_emails($post_id,'failed'). Before sending, the dispatch job re-reads_dn_statuscache-bypassing and aborts if the lead iscomplete/viewed— the cross-gate that stops a lead receiving both a soft-failure email and a results email when a lateaudit.completedsupersedes afailed(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.
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.
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
| 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
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.
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;Fromaddress =hello@demandnow.ai(Q3) - Force Reply-To
- OFF — otherwise the existing
/contactemail'sReply-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/plainalternative viaphpmailer_inithook ($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_mailhooks 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
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:
top10[0].brand). Layout must not hard-assume a single engine — v2 adds ChatGPT.crawl highlights rendered as an issue checklist.If audit.physical_locations_count > 0, the AI Search Visibility section notes the audit was localized: "rankings for searches in {location}".
"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
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
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.
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.
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
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
-
Re-validateReuse
url+ location inputs_parse_audit_fields:HttpUrlparse;physical_locations_count >= 0;> 0⇒ non-emptylocation;== 0⇒locationforced toNULL. Add defense-in-depth rejection oflocalhost, private/loopback IPs, and non-public hosts. Do not trust the caller's normalization. -
ValidateMust be
webhook_urlhttps. Parse withurllib.parse.urlsplit; require exact host equality:hostname.lower()∈WEBHOOK_URL_ALLOWLIST, orhostname == "." + entryfor an explicit allowed parent. No substring /in/ bareendswithmatching.⚡These must all FAILxdemandnow.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.com—hostnameisevil.com; thedemandnow.aiis userinfo, not the host. -
Pre-flight budget checkRead today's
DailySpend(reuse theroutes.py_today_spend_centspattern). Iftotal_cents >= DAILY_COST_CAP_USD * 100, return503before creating any rows. -
Single-transaction write + enqueue
session.add(Audit(status=pending, callback_ref, webhook_url, …))→await session.flush()(load-bearing: obtainsaudit.idbefore the token row; mirrorsPOST /start/{token}inweb/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. Thenenqueue_job("run_audit", audit.id).
{
"audit_id": 123,
"token": "xJ3k7Q…",
"status": "pending"
}
| Code | Condition |
|---|---|
| 401 | Bad or missing X-API-Key |
| 503 | INTEGRATION_API_KEY empty on server |
| 400 | Bad url, webhook_url, or location inputs |
| 503 | Daily budget cap exceeded |
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."
{
"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
| Field | Source | Notes |
|---|---|---|
| audit.stage | Audit.stage column verbatim | "scraping" · "extracting" · "ranking" · null. Plain string, no transformation. |
| keywords_total | COUNT of AuditKeyword rows | 0 until extraction commits its batch. |
| results_completed | COUNT of AuditResult rows | Climbs one-per-pair during ranking (§3.2). Includes errored pairs — see below. |
| total_pairs | keywords_total × len(ENGINES) | Computed from the real ENGINES tuple. Never a hard-coded engine count. |
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.
| Code | Condition |
|---|---|
| 401 | Bad or missing X-API-Key |
| 404 | Unknown 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.
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
| Field | Type | Stringification (payload and base string) |
|---|---|---|
| event | str | as-is: audit.completed | audit.failed |
| token | str | as-is |
| audit_id | int | str(int(audit_id)) — strict integer, never a float |
| status | str | the AuditStatus enum value |
| callback_ref | str | as-is; empty string "" if absent |
| finished_at | datetime | UTC, second precision, Z suffix — strftime('%Y-%m-%dT%H:%M:%SZ'). Never null for a terminal audit. |
HMAC base string
base_string = event + "\n" + token + "\n" + audit_id + "\n" + status + "\n" + callback_ref + "\n" + finished_at
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.
_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 status | Webhook event |
|---|---|
| completed | audit.completed |
| failed · halted_budget_exceeded · cancelled | audit.failed |
Dispatch rules — core/webhook.py
-
Single dispatch pointRefactor
run_audit: collapse its fivereturnstatements into oneresultdict,return resultonce. Place the dispatch block after the innertry/exceptladder, still inside the outertry, beforefinally: redis.aclose(). Skip dispatch whenresult["status"] == "missing". -
Re-validate the effective URLRe-check
webhook_url(or theRESULT_WEBHOOK_URLfallback) againstWEBHOOK_URL_ALLOWLISTimmediately before POSTing — the env fallback is otherwise unchecked. -
Re-SELECT the audit rowOpen a fresh session; re-SELECT the audit. If its status is non-terminal, skip dispatch. Read
finished_athere (avoids the SQL-expression trap above). -
Token lookup
Audithas no ORM relationship toAuditAccessToken. Use:select(AuditAccessToken).where(audit_id == …).order_by(id).limit(1). If no token row exists, skip the webhook — that audit was operator-initiated. -
POST with httpx + tenacityBuild a fresh
httpx.AsyncClient(follow_redirects=False)—follow_redirects=Falseprevents an allowlisted host redirecting to an internal IP (classic SSRF pivot). Retry ≈3 times with backoff viatenacity(both are already inpyproject.toml). A permanently failing webhook is logged, never raised.
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.
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
Revision 0008_audit_callback_ref · down_revision="0007_merge_heads" (the current single head — do not reuse the branched 0004_* numbers).
| Change | Detail |
|---|---|
| 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. |
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:
- Config assertionAssert required config (§15) — fails fast on a missing
GEMINI_API_KEYand other required vars before any work is queued. - Idempotent and safe under Alembic's version-table locking even if
command.upgrade(cfg, "head")webruns it concurrently. Nodocker-compose.ymldepends_on: webchange needed. - Existing call, unchanged.
reconcile_orphans
The first redeploy's upgrade head applies 0005 → 0008 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
| Variable | Purpose |
|---|---|
| INTEGRATION_API_KEY | X-API-Key secret. Empty ⇒ /api/* fails closed (503). |
| WEBHOOK_SECRET | HMAC key for X-DN-Signature. |
| WEBHOOK_URL_ALLOWLIST | Comma-separated exact host allowlist for webhook_url validation. |
| RESULT_WEBHOOK_URL | Fallback 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 key | Staging value | Production value |
|---|---|---|
| dn_audit_api_url | https://audit.demandnow.ai (reachable after Stage 0) | |
| dn_audit_api_key | generated, staging-unique | generated, prod-unique (re-seed if staging was cloned after prod was seeded) |
| dn_audit_webhook_secret | generated, staging-unique | generated, prod-unique |
| dn_audit_team_recipient | andriy.dev005@gmail.com | real sales inbox — seeded at Stage 9 (§16) |
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.
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.
Engine Scope — Gemini-only #
No engine change in v1. One config assertion added. ChatGPT deferred to v2.
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).len(ENGINES) expression in the progress contract (§10.2).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.
Operations & Delivery
How cost and abuse are contained, the ten-stage rollout, and the test plan that gates each stage.
Cost & Abuse Controls #
Eight layered controls — from email verification to poll containment — keep costs bounded and the form non-abusable at v1 scale.
-
Verified email (D2)No audit runs until the lead confirms via the emailed verify link. The most important gate — a real inbox is required.
-
Confirm-button POST (D9)Spend is behind a deliberate
POST. AGET(as emailed links are) is side-effect-free — mail scanners prefetching the link cannot trigger a paid audit. -
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.
-
IP rate limit — 5 / IP / hourApplied on the submission endpoint, own bucket (§9.2). Keyed on
md5(ip). See threat-model note below. -
Email dedupe — 1 audit / email / 30 daysRepeat submits from the same address within 30 days return the existing report (§9.2) — no new audit, no new spend.
-
Free-audit daily cap (
DAILY_COST_CAP_USD)POST /api/auditsreturns503once the cap is reached (coarse gate, §10.1). The worker halt is the real enforcement. Launch value: $20 ≈ 130 Gemini audits/day (Q4). -
Verify-link expiry (48 h) + honeypotLinks 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. -
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.
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
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
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.
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).
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).
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:3007vhost untouched).
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
curlcreates an audit + token; results JSON matches §10.2 (float PSI,Zdatetimes, parsedtop10,stage,progresscounts).- During a running audit,
progress.results_completedclimbs towardtotal_pairs. - Wrong/empty key → 401/503;
webhook_urlxdemandnow.ai/demandnow.ai.evil.com→ 400. physical_locations_count > 0with nolocation→ 400. Lint + tests pass.
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/cancelled→audit.failed; operator audits fire no webhook.alembic current→0008_audit_callback_ref.
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.
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
pendinglead + verify email. - Lead carries
_dn_location/_dn_physical_locations_countand no pre-seeded email-guard flags (_dn_emails_sent/_dn_failure_notifieddo not exist until a send succeeds). - Honeypot / rate-limit / validation per §9.4;
locationrequired only when count > 0; no "ChatGPT" promise remains.
/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
pendinglead 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.
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_mailis re-driven by the cron. - Worker-restart
failed-then-completedlead endscomplete— no soft-failure email sent.
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 toviewed.
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
/contactnotice 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.
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.aisucceeds.
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.
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
-
Verify email delivered promptlyA visitor submits the audit form → verify email arrives within ~1 min.
-
Confirm step on GET — no side effectsThe verify link opens the live audit page on the confirm step. Prefetching or scanner-crawling that link triggers nothing — no audit, no spend.
-
Live progress barClicking "Start my free audit" starts a real audit and the page shows a progress bar advancing in real time through scraping → extracting → ranking.
-
Report renders inline — no manual actionWhen the audit finishes (~1 min) the page reloads itself into an on-brand report on
demandnow.aiwith real Gemini AI visibility, PSI, and crawl findings. -
Durable report + results emailThe same report is reachable at
/audit-report/{token}and via the results email, so closing the tab loses nothing. -
Working CTAThe report ends in a working "book a strategy call" CTA.
-
Team notification with full contextThe team receives a notification with the lead's data (incl. location) and headline findings.
-
Lead state machine walks correctlyThe lead appears in
wp-admin → Audit Leadsand walkspending → running → complete → viewed. -
Call-intent path unchangedThe
intent=callpath and/contactendpoint behavior/payload are unchanged. (Their mail now routes through the SMTP transport — an improvement, not a behavior change.) -
Localized keywords for local businessesA local-business submission (
physical_locations_count > 0+ a city) yields a report whose keyword table is location-localized ("… in {city}"). -
Production rollback still worksThe 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
200 {ok:true, message} — silent discard.physical_locations_count > 0 with empty location → 400 rest_invalid_location. Count = 0 → any submitted location is silently discarded.pending → expired page.pending; verify link is still usable./audit-report/{token} works.complete with exactly one results email + one team email (atomic guards, §12.6).failed, then a late audit.completed supersedes it → ends complete.callback_ref; token backfilled; report link works._dn_report_cache render is served.audit.failed → soft-failure state (inline + email) + team notice. Generic copy — no budget oracle exposed./audit-report/{token} and the live page are not served from full-page cache — PHP re-runs on every request._dn_status write is wp_cache_delete'd so the reloaded GET reads complete, not stale running.wp_options atomic lock has no dependency on a seeded flag row._dn_emails_sent stays unset; the §12.5 cron re-drives the dispatch. Lead is not left silently un-emailed.running / pre-scraping window.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.
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.complete, push the lead to HubSpot, Pipedrive, or similar. v1 stores leads as a WordPress CPT only — no external CRM.cancel_requested flow as a "cancel" control on the live page. v1 has no cancel UI for the visitor.INTEGRATION_API_KEY credentials per client or partner. v1 uses a single shared key.Reference
The file-by-file change map for implementers, and the full revision history.
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 head → reconcile_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. |
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.