diff --git a/app/(marketing)/lead-generation/page.tsx b/app/(marketing)/lead-generation/page.tsx
new file mode 100644
index 00000000..c87f59fe
--- /dev/null
+++ b/app/(marketing)/lead-generation/page.tsx
@@ -0,0 +1,273 @@
+import Link from "next/link";
+import { LEAD_RUN_CREDITS, CREDIT_RACK_CENTS, dollars } from "@/lib/credits";
+import { LEADS_PER_CHARGE } from "@/lib/outreach/billing";
+
+export const metadata = {
+ title: "Lead generation & cold outreach",
+ description:
+ "Point CrawlProof at a directory or a search, and it finds the companies, finds the people, finds the addresses, and writes the email — from your own mailbox, grounded in facts you supply. 3 credits a run.",
+ alternates: { canonical: "/lead-generation" },
+ openGraph: {
+ title: "Lead generation & cold outreach · CrawlProof",
+ description:
+ "Find the companies, find the people, find the addresses, write the email — from your own mailbox, grounded in facts you supply. 3 credits a run.",
+ url: "/lead-generation",
+ },
+};
+
+/**
+ * The sales page for project-level lead generation.
+ *
+ * Every claim here is one the pipeline actually makes good on. Two things it
+ * deliberately does not claim: open tracking and automatic reply detection.
+ * Neither is built, and a lead-gen page that oversells is the exact thing the
+ * drafting guard exists to stop the product doing to other people's inboxes.
+ */
+
+const LADDER = [
+ {
+ step: "1",
+ title: "The site itself",
+ body: "Contact and about pages, footers, mailto: links, and the obfuscated addresses that are written to defeat scrapers.",
+ },
+ {
+ step: "2",
+ title: "Team & leadership pages",
+ body: "A named person outperforms a shared inbox, so /our-team, /leadership and /people are read before anything is paid for.",
+ },
+ {
+ step: "3",
+ title: "Linked PDFs",
+ body: "Capability statements, media kits and data-sheets routinely carry an address the page linking to them never mentions.",
+ },
+ {
+ step: "4",
+ title: "Search",
+ body: "Only now does a run spend on a search call — for the people it has a name for but no way to reach.",
+ },
+ {
+ step: "5",
+ title: "A role address, last",
+ body: "When a company publishes nothing at all, a conventional address like press@ is tried — and marked as a guess, not passed off as found.",
+ },
+];
+
+export default function LeadGenerationPage() {
+ const runRack = LEAD_RUN_CREDITS * CREDIT_RACK_CENTS;
+
+ return (
+
+
+
+ Project feature · Leads
+
+
+ The boring half of cold outreach, done for you
+
+
+ Finding who to email is most of the work and none of the fun. Point a campaign at a
+ directory or a search, and CrawlProof finds the companies, finds the people, finds the
+ addresses, and drafts the email — from your mailbox, in your words, about your thing.
+
+
+
+ Start a campaign
+
+
+ See pricing
+
+
+
+ {LEAD_RUN_CREDITS} credits a run (~{dollars(runRack)}). A run that finds nothing to do
+ costs nothing.
+
+
+
+
+
It reads the directory like a person would
+
+ Most lead directories are a wall of JavaScript. Fetching the HTML gets you an empty shell,
+ which is why so much of this work ends up being done by hand.
+
+
+
+
+
+
+
+
+
+
Directories that name people, not companies
+
+ Plenty of the best lists give you a name, a title and a location and withhold the email —
+ that is the product they are selling. Those are read as people: name, role, employer,
+ location and profile links are kept, and the address becomes something to go and find
+ rather than a reason to discard the lead.
+
+
+
+
+
Five places to look, cheapest first
+
+ The order is deliberate. Everything free is exhausted before anything paid is tried, so a
+ run only spends on the prospects that genuinely publish nothing.
+
+
+ {LADDER.map((l) => (
+
+
+ {l.step}
+
+
+
{l.title}
+
{l.body}
+
+
+ ))}
+
+
+
+
+
The email can only say what you said
+
+ A campaign carries three things: who is writing, the one small thing you are asking for,
+ and a list of checkable facts. Every draft is written from those and then checked against
+ them — a draft that states something outside the list is thrown away rather than sent.
+
+
+
+
+
+
+
+
+
+
+
Sent from your mailbox
+
+ Give it the address you already send from and its settings are discovered automatically —
+ the same lookup your mail client does. Replies come back to you, because the mail came
+ from you.
+
+
+
+
+
+
+
+
+
+
It will refuse to email some people
+
+ Cold outreach earns its reputation one careless send at a time. Privacy, legal, abuse,
+ opt-out and accessibility addresses are never contacted, whatever a page publishes. A
+ postal address for the footer is required before live sending is possible at all, because
+ CAN-SPAM requires one. Anyone who asks to be left alone is suppressed across every
+ campaign you run, permanently.
+
+
+
+
+
Your numbers, not somebody else's
+
+ Sends, replies and closes are counted at three levels: the whole project, each campaign,
+ and each individual run. Rates stay hidden until there is enough volume to mean anything —
+ one reply out of three sends is not a 33% reply rate, and showing it as one invites a
+ decision the sample cannot support.
+
+
+
+
+
What it costs
+
+
+
+ {LEAD_RUN_CREDITS} credits
+
+ per run (~{dollars(runRack)})
+
+
+
+ A campaign run is one pass of the whole funnel: discovery, contact lookup, drafting
+ and sending. A run with nothing to do is not charged, so leaving a campaign switched
+ on between batches is free.
+
+
+
+
+ {LEAD_RUN_CREDITS} credits
+
+ per {LEADS_PER_CHARGE} leads
+
+
+
+ For a one-off search of a directory, priced by how much you ask for. Find nothing and
+ it is refunded.
+
+
+
+
+ Credits are the same ones scans and articles use — no separate plan, no subscription, and
+ they do not expire. Volume packs bring a run down to about{" "}
+ {dollars(Math.round(runRack / 2))}.
+
+
+
+ Get started
+
+
+ Credit packs
+
+
+
+
+
+ Lead generation runs on projects you own. You are responsible for the mail you send and for
+ complying with the rules where your recipients are — CrawlProof enforces a suppression list
+ and a footer address, not the law.
+
+function ModuleCard({
+ emoji,
+ title,
+ body,
+ href,
+}: {
+ emoji: string;
+ title: string;
+ body: string;
+ /** Set only for modules that have a page of their own to go on to. */
+ href?: string;
+}) {
+ const inner = (
+ <>
{emoji}
{title}
{body}
-
+ {href &&
Learn more →
}
+ >
+ );
+ // A card without somewhere to go stays a card. Wrapping every one in a link
+ // to nowhere would make eight of the nine look clickable and do nothing.
+ return href ? (
+
+ {inner}
+
+ ) : (
+
{inner}
);
}
diff --git a/app/(marketing)/pricing/page.tsx b/app/(marketing)/pricing/page.tsx
index 1c59d0b0..0786f454 100644
--- a/app/(marketing)/pricing/page.tsx
+++ b/app/(marketing)/pricing/page.tsx
@@ -3,6 +3,9 @@ import {
CREDIT_PACKS,
CREDIT_RACK_CENTS,
ENGINES,
+ LEAD_RUN_CREDITS,
+ OUTREACH_CREDITS,
+ SCAN_CREDITS,
discountPct,
dollars,
perCreditCents,
@@ -58,6 +61,49 @@ export default function PricingPage() {
})}
+ {/* Credits buy more than scans, and a page that only prices scans makes
+ every other module look like it is either free or unavailable. */}
+
+
What else credits buy
+
+ One balance across the whole platform. No module has its own plan.
+
+
+
+
+ {LEAD_RUN_CREDITS} credits
+
+
Lead generation run
+
+ One pass of a campaign: discovery, contact lookup, drafting and sending. A run with
+ nothing to do is not charged.{" "}
+
+ How it works
+
+
+
+
+
+ {OUTREACH_CREDITS} credit
+
+
Outreach send
+
+ One email, SMS recipient or social post sent by hand, outside a campaign run.
+
+
+
+
+ {SCAN_CREDITS} credits
+
+
Autoblog post
+
+ A researched, illustrated, published long-form article — the same price as an
+ AI-model scan.
+
+
+
+
+
{CREDIT_PACKS.map((p) => {
const off = discountPct(p);
diff --git a/app/actions/leads.ts b/app/actions/leads.ts
index 7f8d547d..9a2fe8c1 100644
--- a/app/actions/leads.ts
+++ b/app/actions/leads.ts
@@ -17,6 +17,7 @@ import { loadAddressSettings } from "@/lib/outreach/postalAddress";
import { discoverProspects } from "@/lib/outreach/discover";
import { upsertContact } from "@/lib/outreach/contacts";
import { generatePitch } from "@/lib/outreach/generatePitch";
+import { leadRunBilling, manualRunPrice } from "@/lib/outreach/billing";
import { runEmailCampaignTick, CAMPAIGN_COLUMNS, summarize, type CampaignRow } from "@/lib/outreach/runner";
type Ok> = { ok: true } & T;
@@ -73,10 +74,21 @@ export async function findLeadsAction(input: {
// A directory can list hundreds of businesses; capping a one-shot run at
// ten meant the form reported a fraction of a page and looked broken.
const limit = Math.min(input.limit ?? 100, 1000);
+ // Priced per hundred leads asked for. A thousand-lead run does ten times the
+ // paid search of a hundred-lead one, so charging both the same would make
+ // the largest runs the cheapest place to spend our money.
+ const price = manualRunPrice(limit);
+ const billing = leadRunBilling(auth.userId, price.credits);
+ if (!(await billing.authorize())) {
+ return {
+ ok: false,
+ error: `Finding up to ${limit} leads costs ${price.credits} credits; not enough balance. Buy credits in Billing.`,
+ };
+ }
// Contact lookup is the part that costs money — up to two SERP calls per
- // prospect that publishes no address — so a thousand-lead run gets an
- // explicit ceiling rather than an unbounded bill.
- const contactSearchBudget = { remaining: Math.min(limit, 100) };
+ // prospect that publishes no address — so the ceiling is what the run paid
+ // for rather than an unbounded bill.
+ const contactSearchBudget = { remaining: price.contactSearches };
const { data: projectRow } = await serviceClient()
.from("projects")
.select("organization_id")
@@ -115,6 +127,8 @@ export async function findLeadsAction(input: {
}
if (!found.prospects.length && !peopleRecorded) {
+ // Nothing to show for it, so nothing to charge for it.
+ await billing.refund();
return { ok: false, error: found.errors.join("; ") || "No businesses found for that search." };
}
@@ -147,8 +161,8 @@ export async function findLeadsAction(input: {
added,
scanning,
note: peopleRecorded
- ? `${added} leads added, ${peopleRecorded} people recorded.`
- : `${added} leads added.`,
+ ? `${added} leads added, ${peopleRecorded} people recorded — ${price.credits} credits.`
+ : `${added} leads added — ${price.credits} credits.`,
};
}
diff --git a/components/leads/campaign-panel.tsx b/components/leads/campaign-panel.tsx
index 5aeea23d..5e411ed9 100644
--- a/components/leads/campaign-panel.tsx
+++ b/components/leads/campaign-panel.tsx
@@ -8,6 +8,7 @@ import {
saveCampaignAction,
toggleCampaignAction,
} from "@/app/actions/leads";
+import { LEAD_RUN_CREDITS } from "@/lib/credits";
export type CampaignRun = {
ran_at: string;
@@ -202,7 +203,8 @@ export function CampaignPanel({
Campaigns
Runs every 15 minutes: finds leads, scans them, writes drafts. Only sends when you turn
- auto-send on.
+ auto-send on. {LEAD_RUN_CREDITS} credits per run that does work — a run with nothing to
+ do is free, so leaving a campaign on between batches costs nothing.