For most B2B companies, somewhere between 95% and 98% of the people evaluating you never fill in a form. They read your pricing page. They open your comparison page in a second tab next to a competitor’s. They forward a link to their VP. Then they leave, and you never know they were there.
For years, the standard fix was Clearbit. Their Reveal API took an IP address and gave you a company, and for a long stretch there was a generous free tier that made it a default line in every growth engineer’s stack. Then HubSpot acquired Clearbit, folded it into Breeze Intelligence, and the open door quietly closed. If you weren’t already a HubSpot customer on the right plan, the capability you’d built your pipeline around simply stopped being available on terms that made sense.
That left a real gap. This is how we filled it — for one of our own products, in about a day of build time — using a new enrichment provider called afterSpark, Cloudflare edge middleware, and a scoring layer that decides what actually deserves a human’s attention.
It is a small system. It is also a good illustration of how we think at October.media: use AI and modern infrastructure to compress something that used to need a vendor contract and a growth team into a single well-shaped edge function that costs less than a coffee a month to run.
What we were actually building
The deliverable is deliberately unglamorous: one Slack channel, two levels of noise.
- A quiet one-line entry for every company we can identify, so we can always see what the filter is doing and tune it against real traffic instead of guesses.
- A rich alert for companies that match our ideal customer profile and show buying intent — at most once per company per day, so it stays worth reading.
That’s the whole product. Everything below is in service of it. And the second bullet is where the business value lives: it is the difference between a data feed and a lead.
A named company on your pricing page is a warm outbound target with a reason to talk. It converts at a multiple of cold outbound because the timing is right — you're reaching them during an evaluation, not hoping to catch one. The entire engineering exercise below exists to make that signal cheap, accurate, and rare enough to trust.
The three problems nobody mentions
Problem 1: capture
The data provider does enrichment: you give it an IP, it gives you a company. It does not watch your website. The capture layer is yours to build.
The good news is that if your site sits behind a CDN or an edge platform, you already have the visitor’s IP server-side, for free, on every request. You don’t need a tag manager, a tracking pixel, or a third-party script. You need to notice what you already have.
Problem 2: cost
This is the one that shapes the architecture. A lookup costs credits, and it costs the same whether the answer is “Acme Health Group” or “a residential broadband customer”. On a normal marketing site, the second answer is the overwhelming majority of your traffic.
So a lookup per page view is the wrong shape twice over: you pay repeatedly for the same visitor, and you pay over and over for consumer ISPs that will never be a lead.
Problem 3: silence
Whatever you build runs in the background, returns nothing to the visitor, and posts to Slack only when it has something to say. That is correct behaviour — and it means a missing config value produces exactly the same observable result as a quiet Tuesday.
Plan for this before you need it. We didn’t, and it cost us more time than the build.
The solution in one picture
Every check is ordered by cost. Nothing reaches the paid call until everything free has had a chance to reject it. That ordering is the cost control.
Cache the negative answer hardest. Once you have paid to learn that an address belongs to a consumer ISP, that fact doesn't change — we keep it for 30 days and never ask again. Most of your traffic is that case, so this one cache does more for your credit balance than everything else combined.
Decision: where to run it
Our first version was a browser script that posted the page path to an API route. We replaced it with edge middleware that runs on the page response itself, and that was the single biggest improvement in the build.
Why: no client JavaScript means nothing to block, nothing to slow down, and nothing stored on the visitor’s device. It fires on the first page view rather than after a script loads, it works with scripting disabled, and ad blockers can’t intercept it.
It is simultaneously more reliable and less invasive than the thing it replaced. Those usually trade against each other. When they don’t, take the win.
What it costs: session depth has to move server-side — a short-lived counter keyed by IP — because you no longer have browser storage to count page views in.
If your site is static or server-rendered behind an edge platform (Cloudflare Pages or Workers, Vercel, Netlify, Fastly), you have this option. If it’s a single-page app where navigation never touches the server, you’ll need the beacon after all — just know you’re paying the costs above.
Step 1: get an API key
Create it against the endpoint you’ll actually call. Keys are scoped per endpoint — it isn’t a permission you widen later, so a second endpoint means a second credential.
Then hard-refresh the keys page and confirm it’s listed as active before you go anywhere else. This takes five seconds and is worth doing: a key that didn’t save produces 401 unauthorized, which is indistinguishable from a bad paste, a wrong variable name, or a deployment that didn’t pick up your config. Eliminating it first saves you debugging three other things.
Assume almost every field in the response is nullable, and write your formatting code for the sparse case from the start.
Two fields carry the whole design: an isISP flag (which protects your budget) and the NAICS/SIC industry codes (which your scoring runs on).
Step 2: set up Slack
Use a bot token, not an incoming webhook. One secret serves every channel, it’s revocable on its own, and chat.postMessage returns real error codes you can act on. An incoming webhook is permanently bound to the single channel it was created for — the channel field in the payload is ignored — so a second channel means a second secret.
Add the chat:write scope, install the app, and take the bot token (xoxb-). Ignore the user token: it acts as a person, carries far broader scope than a service needs, and breaks when that person’s account changes.
Then invite the bot to the channel — /invite @YourApp.
If the bot isn't in the channel, chat.postMessage replies 200 OK with {"ok": false, "error": "not_in_channel"}. Check the response body, never the status code — or a message that was never delivered looks like a successful send, and keeps looking that way forever.
const body = await response.json();
if (!body || body.ok !== true) {
throw new Error(`slack failed: ${body?.error}`);
}
Step 3: create the cache before you write any code
The cache isn’t an optimisation you add later. The function should refuse to run without it, because without it you cannot bound what you spend.
We used Cloudflare KV; anything with TTL support works — Upstash, Redis, DynamoDB. You need five kinds of entry:
| Entry | TTL | Why |
|---|---|---|
isp:<ip> | 30 days | Known consumer/hosting address. The big saving — never pay twice. |
co:<ip> | 24 hours | Resolved company. A returning visitor re-alerts for free. |
miss:<ip> | 7 days | Provider had no answer. Don’t re-ask a question with no answer. |
alert:<domain>:<date> | 24 hours | One rich alert per company per day. |
credits:<YYYY-MM> | 45 days | Running count of paid lookups, checked against a ceiling. |
Cache the company, don’t just skip the visitor. It’s tempting to write “if we’ve seen this IP today, do nothing”. Don’t. Cache the company record instead. Then a company that browsed your homepage this morning and returns to your pricing page this afternoon still triggers an alert — it just doesn’t cost anything.
You dedupe the spend, not the signal. That distinction is worth real money, because the second visit is the one that means something.
Step 4: configuration
Everything is an environment variable, and everything is optional except the switch and the key. Absent means that leg silently does nothing, which makes staged rollout easy.
| Variable | Why it exists |
|---|---|
VISITOR_ENRICH_ENABLED | Master switch. Ship the code inert, turn it on separately, kill it without a code change. |
AFTERSPARK_API_KEY | Encrypted. Server-side only, never in client code. |
SLACK_BOT_TOKEN | Encrypted. |
SLACK_CHANNEL | Where routine identifications go. |
SELF_IPS | Your own offices. Without it you pay to watch yourselves. |
MAX_LOOKUPS_PER_MONTH | Hard ceiling. The blast radius of any bug or leaked key. |
Cloudflare Pages binds environment variables and KV at build time. Setting them against a running deployment does nothing whatsoever — no warning, no error, no hint. You must trigger a new deployment before anything you typed exists. Every "it's still not working" moment in our build traced back to that, or to a value being set on Preview when the site is served from Production.
And set SELF_IPS — then write down that you did. Your office IP probably resolves to a real company, so without it every visit your own team makes costs credits and posts an alert. But we added ours immediately after our first successful test and then spent half an hour convinced the pipeline had broken. It was working perfectly and correctly ignoring us. The address you test from is the address you told it to skip.
Step 5: the function
Two rules shape this code. Serve the page first — identification happens after the response, so a slow API can never delay a visitor. And fail closed — if anything it depends on is missing, spend nothing.
export async function onRequest(context) {
const { request, env, next, waitUntil } = context;
// Serve the page first, always.
const response = await next();
try {
if (env.VISITOR_ENRICH_ENABLED !== 'true') return response;
if (request.method !== 'GET') return response;
// Real page views only: not assets, not API routes, not redirects.
const contentType = response.headers.get('content-type') || '';
if (!contentType.includes('text/html') || response.status !== 200) return response;
const path = new URL(request.url).pathname;
if (!isKnownPage(path)) return response;
// Everything expensive happens after the visitor has their page.
waitUntil(handleView(request, env, path).catch(err => {
console.log('visitor pipeline error:', err && err.message);
}));
} catch (err) {
// A fault in identification must never cost a visitor their page.
console.log('visitor middleware error:', err && err.message);
}
return response;
}
The free rejects, cheapest first — each one saves a paid lookup and costs nothing:
export function shouldSkipRequest({ userAgent, ip, selfIps, country } = {}) {
if (!ip) return { skip: true, reason: 'no-ip' };
if (ip.includes(':')) return { skip: true, reason: 'ipv6' };
if (selfIps?.includes(ip)) return { skip: true, reason: 'self-ip' };
if (BOT_UA.test(userAgent)) return { skip: true, reason: 'bot' };
if (country !== undefined && !ENRICH_COUNTRIES.includes(country))
return { skip: true, reason: 'geo' };
return { skip: false, reason: null };
}
Returning a reason rather than a boolean matters more than it looks — it’s what lets your diagnostics tell you why nothing happened.
On IPv6: afterSpark doesn’t currently resolve IPv6 addresses, so we reject them before spending anything and count the rejections, which makes the miss rate measurable rather than invisible. Worth knowing before you estimate coverage: our own office network turned out to be IPv6-first, which meant our very first browser test could never have worked.
Step 6: deciding what’s worth an alert
A channel that fires on everything gets muted within a week, so this step decides whether the whole thing is useful. This is the part that is genuinely a product decision wearing engineering clothes.
We score two independent axes and require both:
ICP industry match
NAICS/SIC codes matched on prefixes, so you don't enumerate every sibling code. Add a company-name fallback — data providers routinely file operators under real-estate or generic service codes, and matching names against words from your market catches a long tail that codes alone miss.
Buying-signal behaviour
A high-value page (pricing, comparison, contact) or session depth. Someone who read four pages is in an evaluation. Someone who landed on the homepage and left is not.
Fit alone fires on a matching company that bounced off the homepage — a weak signal that trains people to ignore the channel. Intent alone fires on every competitor and vendor who reads your pricing page. Requiring both keeps the loud channel rare enough to stay believed, while the quiet feed preserves everything so you can tune against real data instead of guesses.
Step 7: build a probe before you turn it on
This is the advice we’d most want to hand to anyone attempting this, because it’s the step we skipped and had to come back for.
Remember problem 3: your pipeline is designed to be silent. When it’s wrong it looks exactly like when it’s idle. So build a small token-gated endpoint that reports:
- which settings are present — booleans only, never values;
- whether the cache is actually bound;
- the caller’s IP, its family, country, and whether this request would be skipped and why;
- an ordered list of blocking issues — the first one is what to go and fix;
- cache state for a given IP, and the month’s spend against your ceiling.
Don’t gate the probe behind an environment variable. The most likely fault is that your variables aren’t binding at all, and a probe that needs a variable in order to work cannot diagnose that. Gate it on an unguessable token in the file instead.
A single response then replaces an afternoon of guessing:
{
"config": { "enabled": true, "kvBound": false, "slackChannel": "#web-vistors" },
"request": { "ipFamily": "IPv4", "wouldBeSkipped": true, "skipReason": "self-ip" },
"blockingIssues": [
"VISITOR_CACHE KV namespace is not bound",
"This request would be skipped: self-ip"
]
}
That one payload told us two things at once: the cache wasn’t bound, and — visible in the echoed channel name — someone had typed the channel wrong, which would have failed silently hours later. Echo configured names back. Typos are invisible in a dashboard field and obvious in a diff.
Then remove the dangerous half. Ours briefly had probes that called the paid API and posted to Slack. They were invaluable and they didn’t survive the week: a URL protected only by being hard to guess should not be able to drain a credit balance or write into a team channel.
Better still, have the real pipeline record why its last call failed and let the read-only probe display it. No spend, no writes, and it reports genuine production failures rather than synthetic ones.
Then the scanners arrived
A day after go-live we got a burst of traffic from “Microsoft Corporation” and Starbucks hitting our pricing pages. Flattering, briefly.
It was a scanner sweep — probes for /wp-login.php, /wp-blink.php, /.env. Our lookups had gone from 1 to 14 in a day, almost none of it real traffic.
Two root causes, both worth knowing before they happen to you.
Your host may answer 200 for garbage URLs. With no 404.html, Cloudflare Pages served the homepage with a 200 for every unmatched route, so our “only enrich a successful HTML response” guard passed every probe. A request for /.env looked exactly like someone reading the pricing page.
Adding a real 404 page fixed it at the root — and cleared a pile of soft-404s that search engines count against you anyway. We then added a path allowlist as defence in depth: scanners try thousands of URLs and no denylist keeps pace, but the set of pages you publish is finite and known.
isISP won’t catch cloud infrastructure. The “Microsoft” hits were rented Azure, and they came back with isISP: false. So we maintain our own denylist of cloud and hosting domains — Azure, AWS, GCP, DigitalOcean, Hetzner, OVH — and treat a match exactly like an ISP: remembered for 30 days, never alerted on.
An ISP result is obviously useless. "Microsoft Corporation visited your pricing page" looks like a great lead — you'll act on it, be wrong, and quietly trust the data less. Bad data that looks good is more expensive than bad data that looks bad.
What held: the credit ceiling contained the whole incident to about 26 credits out of 10,000, and the ISP cache prevented repeat spend on the same addresses. Set the cap low while you’re testing. It is the cheapest insurance in this entire build.
The privacy decision, which surprised us
We started behind a cookie-consent gate and moved away from it. The conclusion is counter-intuitive enough to be worth explaining, because the instinct to gate is a good one.
Cookie and ePrivacy rules govern storing or reading data on the visitor’s device. A server-side IP lookup stores nothing there. In our first version the only thing engaging those rules was our own beacon writing a session ID — the identification itself never did.
What remains is a data-protection question, and that’s a question about EU and UK visitors specifically. Gating everyone behind a banner most people ignore was protecting a population we don’t sell to while hiding the one we do.
So we removed the gate, removed all device storage, and excluded EU and UK visitors from processing entirely. The result identifies far more visitors and touches less of their data than the consent-gated version it replaced.
Alongside that, the privacy policy gained a plain-English section: we identify the organisation, never the individual; nothing is stored on your device; residential visitors aren’t identified at all; only visitors in our markets are processed; and there’s an address to have an IP range excluded on request.
This reflects one company's assessment for a US-and-Canada business, and that market limitation is doing a lot of the work. Copy the reasoning — that "is this a cookie question?" and "is this a data-protection question?" are two different questions with two different answers — rather than the conclusion.
Troubleshooting, in the order to check
Almost all of these present identically: silence.
| Check | Symptom | Fix |
|---|---|---|
| Did you redeploy? | Correct settings, zero effect | Config binds at build time |
| Production or Preview? | Settings look right on screen | Check which environment you edited |
| Is the cache bound, not just created? | Refuses to spend anything | Add the binding; names are case-sensitive |
Is the switch exactly true? | Nothing happens at all | True, 1, a trailing space all read as off |
| Does the API key exist? | 401 unauthorized | Verify on the provider’s dashboard |
| Is the bot in the channel? | not_in_channel, HTTP 200 | /invite @App |
| Testing from an excluded IP? | Everything looks broken | Check the skip reason first |
| Test IP cached as residential? | Silence that looks like failure | Build a cache-clear escape hatch |
| Visitors on IPv6? | Silence on modern networks | Not currently resolvable |
What it costs to run
With the ISP memo in place, steady-state cost is roughly one lookup per distinct business IP per day — not one per page view. Everything residential is paid for once and then free for a month.
For a marketing site doing tens of thousands of monthly page views, that lands in the low hundreds of lookups a month. Set the ceiling low, watch real traffic for a few days, then raise it. The economics are not close: one meeting sourced this way pays for years of the pipeline.
Build it so you can remove it
Worth designing in from the start, and worth actually testing. Keep every piece of logic in files that didn’t exist before, touch existing files as little as possible, and verify removal by deleting and rebuilding. Ours came back byte-identical to the build before the feature existed.
Keeping the decision logic pure — no network, no storage, data in and a verdict out — is what makes the scoring unit-testable without a Slack workspace or a credit balance. Ours has 91 tests and they run in under a second.
Where this goes next: from a Slack ping to a pipeline
Everything above ends at a notification. A notification is a trigger, not a destination. The version that actually moves revenue treats the qualified identification as the first event in an automated sequence — and this is the part we’re building out next, and the part we most often build for clients.
Write the company into the CRM automatically
A qualified identification creates or updates a company record with the domain, industry codes, headcount band, location, first-seen date and the pages viewed. Match on domain to avoid duplicates; if the account already exists and is owned, don't create anything — post the signal to the owner instead. An account executive seeing "your account read the pricing page twice this week" is a materially different conversation from cold outreach.
Turn a company into people, with AI doing the legwork
Company-level identification tells you the account, not the human. Chain a contact-discovery provider to find the two or three roles that matter, then use an LLM to read the company's own site and the pages they viewed on yours, and draft a one-paragraph brief: what they do, which of your capabilities is relevant, and the specific hook. This is the step that used to take an SDR twenty minutes per account and now takes about four seconds.
Route into a behaviour-triggered drip, not a newsletter
The sequence should be keyed to what they read, not to a generic cadence. Pricing-page visitors get the ROI case and an offer of a scoping call. Comparison-page visitors get the honest differentiator piece. Documentation readers get the technical deep-dive and an invitation to a working session. Three to four touches, each with a clear exit, and every reply routes to a human immediately.
Score continuously and let the account decay
One visit is a weak signal. Four visits across three weeks, ending on the pricing page, is a buying committee. Keep a rolling account score that increments on each qualified visit and decays over time, and only escalate to a human when it crosses a threshold. This is where the quiet feed pays off — it's the training data for setting that threshold honestly.
Close the loop — measure sourced pipeline, not alerts
Tag every CRM record with its origin so you can answer the only question that matters: how much closed-won revenue started as an anonymous visit? Without that tag the system is a novelty. With it, it's a channel with a cost per opportunity you can compare against paid search — and in our experience it wins that comparison comfortably.
Every one of those steps is a place where a bug becomes an email to a real person at a real company. So the same rules apply as at the edge: hard ceilings on volume, a master switch per stage, a dry-run mode that writes to a log instead of a mailbox, and a human approval gate on anything outbound until the false-positive rate is measured rather than assumed. Ship the pipeline inert, then turn on one stage at a time.
Why we build this way
There’s a version of this project that is a vendor evaluation, a procurement cycle, a six-week integration and a seat-based contract. There’s another version that is one edge function, one cache, a scoring module with 91 tests, and a monthly spend that rounds to nothing.
The second version exists because the tooling changed. Edge platforms give you server-side context for free. New providers like afterSpark unbundled what Clearbit used to sell as a suite. And AI collapses the research-and-drafting work that used to be the reason you needed headcount to act on a signal like this.
That’s the pattern we look for in every engagement: find the part of a business process that has quietly become ten times cheaper to build than it was two years ago, and rebuild it properly — with the guardrails, the tests, and the off-switch — before your competitors notice it moved.