diff --git a/app/(marketing)/page.tsx b/app/(marketing)/page.tsx
index dab3c1a8..39a41e60 100644
--- a/app/(marketing)/page.tsx
+++ b/app/(marketing)/page.tsx
@@ -1,5 +1,6 @@
import Link from "next/link";
import { HeroAuditForm } from "@/components/hero-audit-form";
+import { ANON_DAILY_SCANS, SCAN_CREDITS, SIGNUP_CREDITS } from "@/lib/credits";
import { OrganizationJsonLd, SoftwareApplicationJsonLd, FaqJsonLd } from "@/components/json-ld";
import { PerformancePreview } from "@/components/report/performance-preview";
import { UptimePreview } from "@/components/report/uptime-preview";
@@ -33,7 +34,7 @@ const faqs = [
},
{
q: "Is the free tier really free?",
- a: "Yes — anonymous visitors get 10 audits per day per IP with no signup, and signing up unlocks 20 free credits (1 AI-model scan). Paid scans cost 20 credits (~$1) with volume discounts down to $0.50/scan at the 100-scan pack. No subscription, credits never expire.",
+ a: `Yes — anonymous visitors get ${ANON_DAILY_SCANS} audits per day per IP with no signup, and signing up unlocks ${SIGNUP_CREDITS} free credits (${SIGNUP_CREDITS / SCAN_CREDITS} AI-model scan). Paid scans cost ${SCAN_CREDITS} credits (~$1) with volume discounts down to $0.50/scan at the 100-scan pack. No subscription, credits never expire.`,
},
];
diff --git a/app/(marketing)/pricing/page.tsx b/app/(marketing)/pricing/page.tsx
index a6d738af..c69147b9 100644
--- a/app/(marketing)/pricing/page.tsx
+++ b/app/(marketing)/pricing/page.tsx
@@ -1,14 +1,16 @@
import Link from "next/link";
import {
+ ANON_DAILY_SCANS,
CREDIT_PACKS,
CREDIT_RACK_CENTS,
ENGINES,
LEAD_RUN_CREDITS,
OUTREACH_CREDITS,
SCAN_CREDITS,
+ SIGNUP_CREDITS,
discountPct,
dollars,
- perCreditCents,
+ perCreditLabel,
} from "@/lib/credits";
export const metadata = {
@@ -30,7 +32,7 @@ export default function PricingPage() {
Pay per scan
Rule-based scans are free up to the daily limit. AI-model scans cost{" "}
- 20 credits (~$1) each. Buy credits with crypto via CoinPay;
+ {SCAN_CREDITS} credits (~$1) each. Buy credits with crypto via CoinPay;
no subscription, no expiry.
@@ -131,7 +133,7 @@ export default function PricingPage() {
- Sign-ups include 20 free credits (1 AI-model scan). Anonymous visitors get 10
- free rule-based scans per day per IP.
+ Sign-ups include {SIGNUP_CREDITS} free credits ({SIGNUP_CREDITS / SCAN_CREDITS}{" "}
+ AI-model scan). Anonymous visitors get {ANON_DAILY_SCANS} free rule-based scans
+ per day per IP.
);
diff --git a/app/(marketing)/tools/page.tsx b/app/(marketing)/tools/page.tsx
new file mode 100644
index 00000000..d1cfc51e
--- /dev/null
+++ b/app/(marketing)/tools/page.tsx
@@ -0,0 +1,107 @@
+import Link from "next/link";
+import { ANON_DAILY_SCANS, ENGINES, SIGNUP_CREDITS, type Engine } from "@/lib/credits";
+
+// Every free scanner, in one place.
+//
+// Six of the engines cost nothing, but only two of them — the rule-based AEO
+// audit and the Slop Score — can run without an account, and those two are the
+// only ones the homepage selector offers. So the other four were real, free,
+// and undiscoverable: /pricing listed them among the paid engines, and nothing
+// linked anywhere.
+//
+// The split is stated rather than hidden. Putting all six in the anonymous
+// selector would be the obvious "fix" and the wrong one — four of them would
+// fail on submit, because `ANON_ENGINES` in app/actions/runAudit.ts genuinely
+// only permits two. Telling somebody a tool needs an account is a better
+// experience than letting them find out by having it not work.
+
+export const metadata = {
+ title: "Free website scanners",
+ description:
+ "Every free CrawlProof scanner: AEO audit, Slop Score, DNS analyzer, link checker, specification.website checklist and the Vu1nz security scan.",
+ alternates: { canonical: "/tools" },
+};
+
+/** Runs without an account — kept in sync with ANON_ENGINES in app/actions/runAudit.ts. */
+const ANONYMOUS: Engine[] = ["rule", "slop"];
+
+/** Where each free engine can be started, when it has its own entry point. */
+const START_AT: Partial> = {
+ rule: "/",
+ slop: "/slop",
+};
+
+export default function ToolsPage() {
+ const free = (Object.keys(ENGINES) as Engine[]).filter(
+ (k) => ENGINES[k].cost === 0 && ENGINES[k].available,
+ );
+ const anonymous = free.filter((k) => ANONYMOUS.includes(k));
+ const needsAccount = free.filter((k) => !ANONYMOUS.includes(k));
+
+ return (
+
+
Free website scanners
+
+ {free.length} scanners that cost nothing to run. {anonymous.length} need no
+ account at all — anonymous visitors get {ANON_DAILY_SCANS} scans per day per
+ IP. The rest are free once you sign up, which also includes{" "}
+ {SIGNUP_CREDITS} credits for the AI-model audits.
+
+
+
+
No account needed
+
+ {anonymous.map((k) => (
+
+ ))}
+
+
+
+
+
Free with an account
+
+ Still zero credits to run — signing in is what makes them available.
+
+
+ {needsAccount.map((k) => (
+
+ ))}
+
+
+
+
+ Looking for the AI-model audits?{" "}
+
+ See pricing
+
+ .
+
+ );
+}
diff --git a/app/llms.txt/route.ts b/app/llms.txt/route.ts
index 479e2484..f703ff0b 100644
--- a/app/llms.txt/route.ts
+++ b/app/llms.txt/route.ts
@@ -1,3 +1,4 @@
+import { SCAN_CREDITS, SIGNUP_CREDITS } from "@/lib/credits";
import { env } from "@/lib/env";
const body = `# CrawlProof
@@ -6,7 +7,7 @@ const body = `# CrawlProof
## Product
- Free single-URL AEO audit, 10 per day per IP, no signup required.
-- Signed-in users get 20 free credits (1 AI-model scan); each AI-model scan costs 20 credits (~$1, volume discounts down to $0.50/scan at the 100-scan pack). No subscription — credits never expire.
+- Signed-in users get ${SIGNUP_CREDITS} free credits (${SIGNUP_CREDITS / SCAN_CREDITS} AI-model scan); each AI-model scan costs ${SCAN_CREDITS} credits (~$1, volume discounts down to $0.50/scan at the 100-scan pack). No subscription — credits never expire.
- Saved projects, scheduled re-audits, multi-engine LLM scans (Claude Sonnet 4.6, OpenAI GPT-5 Mini, Gemini 2.5 Pro, Perplexity Sonar Pro, Qwen Plus, Kimi v2.6, DeepSeek V4, Z.AI GLM-5.2), consolidated PDF reports, and diff view.
- Identifies as CrawlProofBot/1.0 (+https://crawlproof.com/bot).
diff --git a/app/sitemap.ts b/app/sitemap.ts
index 83f6e69a..ccdd1c06 100644
--- a/app/sitemap.ts
+++ b/app/sitemap.ts
@@ -15,6 +15,9 @@ export default async function sitemap(): Promise {
{ url: `${base}/pricing`, changeFrequency: "monthly", priority: 0.9 },
{ url: `${base}/hire`, changeFrequency: "monthly", priority: 0.9 },
{ url: `${base}/slop`, changeFrequency: "monthly", priority: 0.9 },
+ // Hub for the six free scanners — the four that need an account had no
+ // inbound link anywhere before this page existed.
+ { url: `${base}/tools`, changeFrequency: "monthly", priority: 0.9 },
{ url: `${base}/get-guide`, changeFrequency: "monthly", priority: 0.9 },
{ url: `${base}/about`, changeFrequency: "monthly", priority: 0.7 },
{ url: `${base}/press`, changeFrequency: "monthly", priority: 0.6 },
diff --git a/components/hero-audit-form.tsx b/components/hero-audit-form.tsx
index 5ac86d0d..dad4e9a3 100644
--- a/components/hero-audit-form.tsx
+++ b/components/hero-audit-form.tsx
@@ -2,6 +2,7 @@
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
+import Link from "next/link";
import { startAuditFromForm } from "@/app/actions/runAudit";
// Datafa.st injects `window.datafast` once its script.js loads. Calls
@@ -134,7 +135,16 @@ export function HeroAuditForm({ defaultScan = "rule" }: { defaultScan?: HeroScan
No email, no signup, no card. The report opens on-page in seconds — you
- can email yourself a PDF copy from the report once it's ready.
+ can email yourself a PDF copy from the report once it's ready.{" "}
+ {/* Only two of the six free scanners can run anonymously, so the other
+ four were invisible from here — real, free, and reachable by nobody.
+ A link rather than four more radio buttons: the rest genuinely
+ require an account, and offering them in this selector would mean
+ submitting a form that cannot succeed. */}
+
+ All free tools
+
+ .
-
+ {/* Email alone, and nothing else, until the visitor asks for more.
+
+ The form previously showed three inputs for what is a one-field
+ request: a phone number and "monthly website sales" sat next to the
+ email, both marked optional. Optional or not, being asked your
+ revenue in order to receive a PDF reads as a sales qualification,
+ and it is the wrong trade at the moment somebody is doing us the
+ favour of handing over an address. The commercial questions are
+ worth asking — but behind something the visitor chose, for a thing
+ they get in return. */}
+
setMonthlySales(e.target.value)}
+ type="email"
+ placeholder="you@example.com"
+ value={email}
+ onChange={(e) => {
+ setEmail(e.target.value);
+ if (error) setError(null);
+ }}
className="input"
+ autoComplete="email"
/>
+
+ {!wantsEstimate ? (
+
+ ) : (
+
+
+ We'll add a revenue-impact estimate to the report. Both fields are
+ optional — the PDF sends either way.
+
+ )}
+
diff --git a/components/report/performance-charts.tsx b/components/report/performance-charts.tsx
index 940c5859..df55d132 100644
--- a/components/report/performance-charts.tsx
+++ b/components/report/performance-charts.tsx
@@ -1,5 +1,7 @@
"use client";
+import { SCAN_CREDITS, SIGNUP_CREDITS } from "@/lib/credits";
+
import dynamic from "next/dynamic";
import Link from "next/link";
@@ -84,7 +86,8 @@ export function PerformanceCharts({ overlayTitle, overlayBody }: PerformanceChar
Sign up free
- 3 free AI credits on signup · free rule-based scans up to the daily limit.
+ {SIGNUP_CREDITS} free credits on signup ({SIGNUP_CREDITS / SCAN_CREDITS} AI-model
+ scan) · free rule-based scans up to the daily limit.
diff --git a/lib/credits.ts b/lib/credits.ts
index adcd2bf5..52105087 100644
--- a/lib/credits.ts
+++ b/lib/credits.ts
@@ -12,6 +12,31 @@ export const CREDIT_RACK_CENTS = 5;
// auto-fix). 20 × $0.05 = $1.00 rack, unchanged from the old 1-credit-=-$1 era.
export const SCAN_CREDITS = 20;
+/**
+ * Credits a new account starts with.
+ *
+ * **Mirrors the database**: `profiles.credits_balance` has a column default of
+ * 20, and that default is what actually grants the credits — this constant
+ * only describes it for the marketing copy. Changing this number alone changes
+ * what the site promises, not what a signup receives; the column default has to
+ * move with it.
+ *
+ * It exists because the promise had already drifted. The report paywall card
+ * said "3 free AI credits on signup" while /pricing, the homepage FAQ, llms.txt
+ * and the JSON-LD all said 20 — so a visitor comparing two pages saw a number
+ * that decided whether signing up unlocks a paid scan at all. Every one of
+ * those strings now reads from here.
+ */
+export const SIGNUP_CREDITS = 20;
+
+/**
+ * Free rule-based scans an anonymous visitor gets per day, per IP.
+ *
+ * Same reasoning as SIGNUP_CREDITS: quoted on four separate pages, and a
+ * number quoted in four places is a number that will disagree with itself.
+ */
+export const ANON_DAILY_SCANS = 10;
+
// Credits charged for one outreach send (email / SMS recipient / social post).
export const OUTREACH_CREDITS = 1;
@@ -119,10 +144,34 @@ export function perScanCents(pack: CreditPack): number {
}
// Effective price of a single credit under this pack, in cents.
+//
+// Rounded, and therefore NOT safe to display on its own — see
+// `perCreditLabel`. Kept for callers doing comparison or sorting, where a whole
+// cent is precise enough.
export function perCreditCents(pack: CreditPack): number {
return Math.round(pack.amountCents / pack.credits);
}
+/**
+ * Exact per-credit price, as displayed.
+ *
+ * Every pack's true per-credit price lands on a half-cent — $9/200 = $0.045,
+ * $35/1000 = $0.035, $50/2000 = $0.025 — and rounding half-up sent all three
+ * *upward*, so the page advertised $0.05, $0.04 and $0.03. Overstating your own
+ * price on the pricing page is a strange way to lose an argument with a
+ * customer who can do the division.
+ *
+ * Three decimals, with a trailing zero trimmed only down to two, so a pack that
+ * really is a round number ($1.00/20 = $0.05) reads "$0.05" rather than
+ * "$0.050" — and a hypothetical $0.10 never degrades to "$0.1", which reads as
+ * a typo rather than a price.
+ */
+export function perCreditLabel(pack: CreditPack): string {
+ const exact = pack.amountCents / pack.credits / 100;
+ const three = exact.toFixed(3);
+ return `$${three.endsWith("0") ? three.slice(0, -1) : three}`;
+}
+
// ----- Engines (free utilities + partner scanners + LLM providers) ----------
export type Engine =
| "rule"
diff --git a/tests/credits-per-credit-label.test.ts b/tests/credits-per-credit-label.test.ts
new file mode 100644
index 00000000..4b4a0468
--- /dev/null
+++ b/tests/credits-per-credit-label.test.ts
@@ -0,0 +1,69 @@
+// The pricing page was overstating its own prices.
+//
+// Every bundle's true per-credit price lands on a half-cent, and
+// `Math.round` on integer cents took all three of them UP: $0.045 shown as
+// $0.05, $0.035 as $0.04, $0.025 as $0.03. Reported by a visitor who did the
+// division, which is the worst way to find out.
+
+import { describe, expect, it } from "vitest";
+import {
+ CREDIT_PACKS,
+ SIGNUP_CREDITS,
+ SCAN_CREDITS,
+ findPack,
+ perCreditLabel,
+} from "@/lib/credits";
+
+describe("perCreditLabel", () => {
+ it.each([
+ ["pack-1", "$0.05"], // $1.00 / 20 — genuinely round
+ ["pack-10", "$0.045"], // $9 / 200, was shown as $0.05
+ ["pack-50", "$0.035"], // $35 / 1000, was shown as $0.04
+ ["pack-100", "$0.025"], // $50 / 2000, was shown as $0.03
+ ])("prices %s at %s", (id, expected) => {
+ const pack = findPack(id);
+ expect(pack).toBeDefined();
+ expect(perCreditLabel(pack!)).toBe(expected);
+ });
+
+ it("never overstates the price", () => {
+ // The property that actually matters: what we print must be <= what we
+ // charge, for every pack, including any added later.
+ for (const pack of CREDIT_PACKS) {
+ const printed = Number(perCreditLabel(pack).slice(1));
+ const actual = pack.amountCents / pack.credits / 100;
+ expect(printed).toBeLessThanOrEqual(actual + 1e-9);
+ }
+ });
+
+ it("keeps at least two decimals, so a price never reads as a typo", () => {
+ for (const pack of CREDIT_PACKS) {
+ const decimals = perCreditLabel(pack).split(".")[1] ?? "";
+ expect(decimals.length).toBeGreaterThanOrEqual(2);
+ expect(decimals.length).toBeLessThanOrEqual(3);
+ }
+ });
+
+ it("is exact to three decimals for every pack", () => {
+ for (const pack of CREDIT_PACKS) {
+ const printed = Number(perCreditLabel(pack).slice(1));
+ const actual = pack.amountCents / pack.credits / 100;
+ expect(printed).toBeCloseTo(actual, 3);
+ }
+ });
+});
+
+describe("signup credits", () => {
+ it("is one AI-model scan, which is what the copy claims", () => {
+ // Several pages say "N free credits (1 AI-model scan)". If these ever
+ // diverge, that sentence becomes false everywhere at once.
+ expect(SIGNUP_CREDITS).toBe(SCAN_CREDITS);
+ expect(SIGNUP_CREDITS / SCAN_CREDITS).toBe(1);
+ });
+
+ it("matches the profiles.credits_balance column default of 20", () => {
+ // The database default is what actually grants the credits; this constant
+ // only describes it. Pinned so a change here without a migration is loud.
+ expect(SIGNUP_CREDITS).toBe(20);
+ });
+});