The short answer
A signal is a recorded event that suggests an account might buy: a visit to your pricing page, a spike in research on a topic, a job change. A signal pipeline catches those events, matches them to an account, counts them over a time window, removes duplicates, and sends one useful alert to the person who can act. A GTM engineer owns this pipeline because a bad one either misses the moment or buries sales in noise, and then sales stops reading it.
Why this matters
The roadmap's one-line reason is: "In B2B, timing beats the pitch. You must strike while the intent is hot." Treat that as a claim to test. Here is what the evidence supports.
Speed matters for inbound requests. The best-known number comes from Dr. James Oldroyd's 2007 Lead Response Management Study, cited in a 2014 InsideSales report: the odds of making contact were 100 times greater when the first attempt came within 5 minutes than after 30 minutes, and the odds of qualifying were 21 times greater. Two cautions: a sales-software vendor wrote the report, and these people filled in a form asking to be contacted. That is not an anonymous company reading your pricing page.
The peer-reviewed cousin is "The Short Life of Online Sales Leads" (Harvard Business Review, March 2011). A secondary analysis reports that it timed responses at 2,241 US companies, that its fastest bucket was "within an hour", and that firms reaching a lead within an hour were nearly 7 times as likely to qualify it as firms trying an hour later. The same analysis says the paper never measured a 5-minute window. The 5-minute figure and the HBR paper are different sources that people mix up.
Being on the list early matters too. The 6sense 2025 B2B Buyer Experience Report (nearly 4,000 buyer responses) found that buyers first engage a seller about 61% of the way through their buying journey, and that they choose from a vendor on their "Day One Shortlist" 95% of the time. 6sense sells intent data, so read it with that in mind. Still, much of the decision happens before a salesperson hears from the buyer.
What is not proven. I found no controlled study showing that alerting a rep within X minutes of an anonymous account visit raises win rate. Vendors publish customer-result numbers, but treat them as marketing until you can check the method.
Verdict: half right. Fast follow-up on a clear request has real evidence. "Strike while hot" for anonymous intent is plausible and unproven. Build the pipeline, then measure whether alerts lead to meetings.
Core concepts
Two words come up all the way through. An account is a company, as opposed to one person at it. A target account is a company your sales team has chosen to win. That list usually lives in a CRM (customer relationship management system, the database sales uses to track companies, contacts and deals). A signal only matters when it lands on a target account.
First-party, second-party and third-party intent
Intent data is behavior that suggests interest. Where it comes from decides how far you can trust it.
| Type | Source | Example | Weakness |
|---|---|---|---|
| First-party | Your own properties | Pricing visits, sign-ups, email clicks | Only sees accounts that already found you |
| Second-party | Another company's first-party data, shared or sold to you | G2 Buyer Intent: who viewed your G2 profile or compared you with a rival | One site only, price on request |
| Third-party | A network of many sites | Bombora Company Surge | Topic level, weekly, coarse |
Third-party intent works like this. (A topic is a subject such as "compliance automation".) A data co-op (a group of publishers that share anonymized reading data) reports which companies read which topics. The vendor compares each company's recent reading on a topic with that company's own earlier baseline and turns the jump into a score. In Common Room's Bombora documentation, an account is "surging" on a topic when its score is above 60, and scores update once a week. 6sense's FAQ says that inside 6sense an account shows intent only at a score of 70 or more, and that you cannot change it. Same data, different cut-offs, so ask which threshold a tool applies. These details come from partner documentation, not Bombora's own site, which I could not open, so check Bombora's definitions before you buy.
Worked example. First-party: Acme viewed /pricing twice. Second-party: Acme compared you with a rival on G2. Third-party: Acme is surging on "compliance automation". Any one is weak. All three in one week deserve a human's time.
Website deanonymization
Deanonymization means working out who an anonymous visitor is. There are two levels.
Company level. The visitor's IP address is matched against a database of which organizations own which address ranges. This is reverse IP lookup. It never names the person. Clearbit's Reveal page works the same way: it says it turns "anonymous website traffic into real buying intent signals" using "IP intelligence data", and its visitor dashboard lists companies that match your ideal customer profile (the kind of company you want to sell to), not named people.
Person level. Some tools go further and name a person, with an email address. RB2B's pricing page sells this as "contact-level" identification, says it is US only, and says its free plan gives company-level identification only.
Accuracy limits. Reverse IP breaks in common ways. Remote workers use home internet that maps to an internet provider. VPNs map to the proxy owner. Shared networks put unrelated people behind one address, and HubSpot's own docs warn that identification is probabilistic and can give false positives.
How often does it work? Vendors disagree. RB2B's pricing page lists "15-20% Coverage" for company-level identification on its Free to Pro plans and "35-45% Coverage" on Pro+. Common Room's visitor-identification guide says "realistic company-level match rates at roughly 30-65% of US B2B traffic." I found no independent, audited benchmark. Treat every match rate as marketing until you measure your own with known test visits.
What match rate does to your alerts (made-up numbers to show the shape):
| Low-coverage tool | High-coverage tool | |
|---|---|---|
| Monthly B2B visits | 2,000 | 2,000 |
| Match rate | 15% | 45% |
| Identified visits | 300 | 900 |
| Of those, from target list (5%) | 15 | 45 |
Either way you miss many target visits, so some accounts fire late or never. Tell sales that on day one.
Clearbit in October 2026. What I could confirm:
- HubSpot completed its acquisition of Clearbit on December 4, 2023.
- The Clearbit changelog records that the free platform, Weekly Visitor Report, Connect, TAM Calculator and free Slack integration ended April 30, 2025, and the free Logo API shut down December 8, 2025. It points users to Breeze Intelligence.
- clearbit.com reads "Clearbit has joined HubSpot!".
- HubSpot sells visitor identification as Buyer Intent. It needs a Starter, Professional or Enterprise subscription (on Marketing Hub, Sales Hub, Service Hub, Data Hub or Content Hub) plus HubSpot Credits. Tracking a company costs 10 credits, and the Data Hub pricing page lists credits at $0.010 each. HubSpot's FAQ says it uses its own reverse-IP technology and that identification is probabilistic.
What I could not confirm: any page saying "Reveal is shut down" or "Reveal became Breeze Intelligence". The Reveal page still loads, but I found no public API documentation or plan details for it. Before you build on Reveal, ask the vendor in writing what it covers today, and ask anyone who says "Clearbit Reveal" which product they mean.
The wider field. Koala's homepage reads "Koala has been acquired by Cursor and will be shutting down on Sept 30". The banner gives no year, so confirm the date before you rely on Koala. Leadfeeder links to "Dealfront is now Leadfeeder". Vendors change often, so keep the visitor-ID vendor swappable behind one internal event format.
Privacy law for deanonymization
This is engineering guidance, not legal advice. Talk to counsel before you ship.
- GDPR (EU). Recital 30 names IP addresses and cookie identifiers as online identifiers that can relate to a person. In Breyer (CJEU case C-582/14, 19 October 2016) the court held that a dynamic IP address can be personal data for a website operator with legal means to identify the visitor. You then need a lawful basis under Article 6: consent, or legitimate interests that are not overridden by the visitor's rights.
- ePrivacy (EU) and PECR (UK). A separate rule covers storing or reading information on a visitor's device: consent by default, narrow exceptions. The UK ICO's guidance on storage and access technologies (finalised 29 April 2026) covers device fingerprinting, not only cookies. A Digital Omnibus proposal of 19 November 2025 would change these rules (see the EDPB and EDPS joint opinion), but it is only a proposal.
- CCPA/CPRA (California). The Attorney General's page says residents can opt out of the sale or sharing of personal information, and businesses must honor opt-outs within 15 business days. One coverage test is gross annual revenue above $25 million (check the current adjusted figure).
- The vendor does not carry your duty. RB2B's privacy policy offers an opt-out form, and HubSpot's docs say you may need a consent banner on your own site.
Practical rules: load the visitor-ID script only after consent where required, prefer company-level identification, keep retention short, keep personal data out of wide Slack channels, and build delete jobs from day one.
Event streaming and webhooks
An event is a small record that something happened: who, what, when. A webhook is a URL on your server that another system calls (an HTTP POST) each time an event happens. Event streaming, in Apache Kafka's words, is "capturing data in real-time from event sources" as streams of events. Kafka stores events in topics, splits each topic into partitions across servers, and keeps events for a retention period you set. Many programs can read a topic independently and replay it.
Most GTM pipelines start simpler:
| Pattern | What it is | Use it when |
|---|---|---|
| Webhook | A vendor POSTs to your URL when something happens | One source, one consumer, working today |
| Event stream | Kafka or similar, with replay and many consumers | Several teams share the events |
| Event router | Segment or RudderStack collects and fans out events | One tracking plan, many destinations |
The Segment spec is the shape most tools copy. A page view is a page call (Page spec), and a custom action is a track call (Track spec). Here is a trimmed page call. The values are made up, and the router below reads only these fields:
{
"type": "page",
"messageId": "ajs-f8ca1e4de5024d9430b3928bd8ac6b96",
"anonymousId": "23adfd82-aa0f-45a7-a756-24f2a7a4c895",
"timestamp": "2026-10-07T14:03:11.249Z",
"properties": {"path": "/pricing", "title": "Pricing"},
"context": {"ip": "203.0.113.7"}
}messageId is a unique id (at most 100 characters per the common fields page) and your dedup key. anonymousId is a pseudo-id for a visitor with no login. context.ip is filled in by Segment's servers for client-side events. Segment also has timestamp and receivedAt, so choose one on purpose.
Retries mean the same event can arrive more than once, so design for at-least-once delivery (every event arrives one or more times). Duplicates are normal. Segment's Webhooks destination retries timeouts and 5xx responses for up to four hours and expects a reply within 5 seconds. Your endpoint must answer fast and be safe to call twice. When you set a shared secret, Segment signs the JSON body and sends the result in the X-Signature header. The signature is an HMAC-SHA1 hex digest. An HMAC is a short fingerprint made from the message plus a secret key. Only someone who knows the key can produce a matching one, so a match proves the sender is real and the body was not changed. Verify it before you trust anything. RudderStack offers an open source version that depends only on PostgreSQL.
Windowed counting
"3 visits in one week" is a windowed count: count events inside a time window. Kafka Streams documents the window types: tumbling (fixed, non-overlapping buckets), hopping (fixed size, overlapping), sliding (moves with each event) and session (closes after a gap of inactivity).
Worked example. Three different visitors from Acme open the pricing page on Thursday, Friday and the next Monday.
| Window | Counted | Alert? |
|---|---|---|
| Tumbling calendar week (Mon to Sun) | Thu and Fri in week 1 (2), Mon in week 2 (1) | No, never reaches 3 |
| Rolling 7 days, checked at each event | On Monday the last 7 days hold all three | Yes, on Monday |
Rolling windows match what a human means by "in one week". Define "a visit" before you count. A refresh is not a second visit. The project below counts distinct visitor-and-day pairs.
Dedup, idempotency and alert fatigue
Idempotent means doing the same operation twice has the same result as doing it once. You get there with a unique key (messageId) and an insert that ignores repeats. Dedup before you count, or a retry inflates the count and fires a false alert.
Alert fatigue is people ignoring alerts because there are too many. Defenses, in order of value:
- Alert on the threshold crossing, not on every event after it.
- Add a cooldown: one alert per account per window.
- Filter to target accounts before counting.
- Put the reason in the alert: which page, how many visits.
- Review weekly. Delete rules nobody acts on.
Made-up arithmetic: 4 visitors a day for a week is 28 events. With no cooldown, 26 alerts fire (every event from the third on). With one, 1.
Slack incoming webhooks and Block Kit
An incoming webhook is a URL you POST JSON to. Success is HTTP 200 with the body ok. The URL holds a secret, and Slack "actively searches out and revokes leaked secrets". You cannot override the channel per request. The rate limit is 1 message per second with short bursts allowed. Above it you get HTTP 429 and a Retry-After header.
The smallest working message is one line of JSON. Run this with your own webhook URL:
curl -X POST -H "Content-type: application/json" --data '{"text": "Acme viewed pricing 3 times in 7 days"}' "$SLACK_WEBHOOK_URL"Slack answers ok. A richer payload is text (a fallback for notifications, which Slack's own examples keep even when they use blocks) plus blocks (Block Kit, Slack's JSON layout format). Limits from the docs: header text 150 characters, section fields up to 10 items, button text 75. A good alert has a header (the account), a section with 2 to 4 fields (the numbers), a button (open the account) and a context line (the kind of signal and how sure you are).
Key terms
The tools
| Tool | What it does | Pricing model | Notes |
|---|---|---|---|
| HubSpot Buyer Intent | Company-level visitor ID, research intent and news signals inside HubSpot | Subscription plus HubSpot Credits | HubSpot's own visitor ID, built on its reverse-IP technology. Tracking is 10 credits per company. |
| RB2B | Person and company visitor ID, push to Slack | Free, then $79, $149, $199 per month | Free plan: 150 resolutions a month, company only. Person-level is US only. |
| Leadfeeder | Reverse-IP company ID with CRM sync | Free, then from 79 euros per month | Free plan: 100 identified companies a month, 7-day history. |
| Snitcher | Company-level visitor ID | From $49 per month by volume | 14-day trial, no card. Slack and API included. |
| Warmly | Visitor ID, alerts and AI chat | Annual plans from $10,000 | No free tier advertised. Person-level. |
| Common Room | Web, product, community and intent signals, with Bombora topics | From $2,500 per month, annual | Essential includes 6 Bombora topics. No free plan. |
| 6sense | Intent data, web deanonymization, account scoring | Quote based | Built for account-based marketing (ABM: focusing sales and marketing effort on a named list of companies). Demandbase is a rival, also quote based. |
| Bombora | Third-party topic intent from a publisher co-op | Quote based, no public price confirmed | Often resold inside other tools. Site not reachable for verification. |
| Segment | Collects events and routes them to destinations | Monthly tracked users, contact sales | Trial available. Defines the Track spec used here. |
| RudderStack | Segment-style event routing, self-hostable | Open source version needs PostgreSQL | The free route for event routing. |
Free route: a plain FastAPI endpoint (below), a free Slack workspace, IPinfo Lite for unlimited country and ASN lookups (an ASN, or autonomous system number, identifies one network block and its owner), and mock events.
Build the project
You will build a webhook router. A page-view event arrives. The router checks it is a pricing view from a target enterprise account, ignores repeats, counts distinct visits in a rolling 7 days, and posts one Slack alert when the count reaches 3. State lives in SQLite, so you need no other infrastructure.
| Piece | Job | File |
|---|---|---|
| Receiver | Verifies the signature, parses the event | router.py |
| Resolver | Gets the company domain from the event, or from IPinfo Lite | router.py |
| Target list | The enterprise accounts that count | targets.csv |
| Store | Dedup and windowed count in SQLite | router.py |
| Notifier | Slack Block Kit alert, retry on 429 | router.py |
| Simulator | Sends mock Segment-style events | simulate.py |
- Create a project folder and a virtual environment inside it.
- Install the four packages in the shell block below.
- Save
targets.csvas shown below, then add a few real or invented accounts. Each row is domain, display name, segment. - Create a Slack incoming webhook for a test channel and export its URL as
SLACK_WEBHOOK_URL. Skip this step to run in dry-run mode, which prints the alert instead. - Save
router.py. - Save
test_router.pyand runpytest -q. All five tests must pass before you go on. - Start the server with
uvicorn, and leave that terminal open. - Open a second terminal and run
python simulate.py acme.com. Confirm the third visitor triggers one alert and the fourth does not. - Run
python simulate.py acme.comagain. Confirm every response saysduplicate, because the simulator reuses the samemessageIdvalues. - Stop the server, set
SEGMENT_SHARED_SECRETin both terminals, and restart the server. Confirm that the unsignedcurlcall returns HTTP 401 and thatsimulate.pystill works. - Optional: send a real page call from Segment's Webhooks destination or a RudderStack webhook through a tunnel tool (it gives your laptop a temporary public URL), and compare the real payload with the mock one. A real payload has no
company_domain, so setIPINFO_TOKENand expect most visits to be ignored.
python -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install fastapi uvicorn httpx pytest
export SLACK_WEBHOOK_URL="https://hooks.slack.com/services/..." # keep out of git
pytest -q
uvicorn router:app --port 8000
# in a second terminal, same folder, venv active:
python simulate.py acme.com
rm -f signals.db signals.db-wal signals.db-shm # reset state between experiments (stop the server first)
# step 10: export SEGMENT_SHARED_SECRET="choose-a-long-random-string" in both terminals, then
curl -i -X POST http://127.0.0.1:8000/events -H "content-type: application/json" -d '{}'targets.csv:
domain,name,segment
acme.com,Acme Corp,enterprise
globex.com,Globex,enterprise
smallshop.io,Small Shop,smbrouter.py:
import csv, hashlib, hmac, json, os, sqlite3, time
from typing import Optional
from urllib.parse import quote
import httpx
from fastapi import BackgroundTasks, Depends, FastAPI, Header, HTTPException, Request
DB_PATH = os.environ.get("ROUTER_DB", "signals.db")
SLACK_WEBHOOK_URL = os.environ.get("SLACK_WEBHOOK_URL", "")
SHARED_SECRET = os.environ.get("SEGMENT_SHARED_SECRET", "")
IPINFO_TOKEN = os.environ.get("IPINFO_TOKEN", "")
TARGETS_CSV = os.environ.get("TARGETS_CSV", "targets.csv")
WINDOW_DAYS = int(os.environ.get("WINDOW_DAYS", "7"))
THRESHOLD = int(os.environ.get("THRESHOLD", "3"))
ISP_DOMAINS = {"comcast.com", "att.com", "verizon.com", "spectrum.com", "cox.com"}
app = FastAPI()
def db():
conn = sqlite3.connect(DB_PATH, isolation_level=None, timeout=10)
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("""CREATE TABLE IF NOT EXISTS views (message_id TEXT PRIMARY KEY,
domain TEXT NOT NULL, visit_key TEXT NOT NULL, ts INTEGER NOT NULL)""")
conn.execute("CREATE INDEX IF NOT EXISTS views_domain_ts ON views(domain, ts)")
conn.execute("CREATE TABLE IF NOT EXISTS alerts (domain TEXT NOT NULL, ts INTEGER NOT NULL)")
return conn
def load_targets():
with open(TARGETS_CSV, newline="", encoding="utf-8") as f:
return {r["domain"].strip().lower(): r for r in csv.DictReader(f)}
def signature_ok(raw, header):
# Segment signs with HMAC-SHA1 (hex) in X-Signature. No secret set = skip.
if not SHARED_SECRET:
return True
if not header:
return False
candidates = [raw]
try: # Segment's docs sign JSON.stringify(body), so also try compact JSON
candidates.append(json.dumps(json.loads(raw), separators=(",", ":"),
ensure_ascii=False).encode())
except ValueError:
pass
key = SHARED_SECRET.encode()
given = header.strip().lower().encode()
return any(hmac.compare_digest(hmac.new(key, c, hashlib.sha1).hexdigest().encode(), given)
for c in candidates)
def resolve_domain(evt):
# Use the domain a visitor-ID vendor attached; else try IPinfo Lite.
dom = ((evt.get("properties") or {}).get("company_domain") or "").strip().lower()
ip = (evt.get("context") or {}).get("ip")
if dom or not (ip and IPINFO_TOKEN):
return dom or None
try:
r = httpx.get(f"https://api.ipinfo.io/lite/{ip}", params={"token": IPINFO_TOKEN}, timeout=3)
dom = (r.json().get("as_domain") or "").lower() if r.status_code == 200 else ""
except (httpx.HTTPError, ValueError):
return None # a lookup failure must not break the endpoint
return None if dom in ISP_DOMAINS else dom or None
def slack_blocks(target, count):
name, domain = target["name"], target["domain"]
return {
"text": f"{name} viewed pricing {count} times in {WINDOW_DAYS} days",
"blocks": [
{"type": "header", "text": {"type": "plain_text", "text": f"Hot account: {name}"[:150]}},
{"type": "section", "fields": [
{"type": "mrkdwn", "text": f"*Account*\n{domain}"},
{"type": "mrkdwn", "text": f"*Pricing visits*\n{count} in {WINDOW_DAYS} days"},
{"type": "mrkdwn", "text": f"*Segment*\n{target['segment']}"}],
"accessory": {"type": "button", "text": {"type": "plain_text", "text": "Open account"},
"url": f"https://crm.example.com/search?q={quote(domain)}"}},
{"type": "context", "elements": [
{"type": "mrkdwn", "text": "First-party signal. Company-level match, not a person."}]},
],
}
def post_to_slack(payload):
if not SLACK_WEBHOOK_URL:
print("DRY RUN:", json.dumps(payload)[:200])
return
try:
r = httpx.post(SLACK_WEBHOOK_URL, json=payload, timeout=5)
if r.status_code == 429:
time.sleep(int(r.headers.get("Retry-After", "1")))
r = httpx.post(SLACK_WEBHOOK_URL, json=payload, timeout=5)
r.raise_for_status()
except httpx.HTTPError as exc: # runs after the reply is sent: log it, do not crash
print("SLACK FAILED:", exc)
async def raw_body(request: Request) -> bytes:
return await request.body()
@app.post("/events")
def events(background: BackgroundTasks, raw: bytes = Depends(raw_body),
x_signature: Optional[str] = Header(None)):
# A plain "def" route runs in a worker thread, so SQLite and HTTP calls do not
# block the server. The Slack post runs after the reply, inside Segment's 5 s limit.
if not signature_ok(raw, x_signature):
raise HTTPException(401, "bad signature")
try:
evt = json.loads(raw)
except ValueError:
raise HTTPException(400, "body is not JSON")
if not isinstance(evt, dict) or not evt.get("messageId"):
raise HTTPException(400, "messageId is required")
path = (evt.get("properties") or {}).get("path")
if evt.get("type") not in ("page", "track") or path not in ("/pricing", "/pricing/"):
return {"status": "ignored"}
domain = resolve_domain(evt)
target = load_targets().get(domain or "")
if not target or target["segment"] != "enterprise":
return {"status": "not a target"}
now = int(time.time())
visitor = evt.get("anonymousId") or evt.get("userId") or "unknown"
visit_key = f"{visitor}:{time.strftime('%Y-%m-%d', time.gmtime(now))}"
conn = db()
try:
conn.execute("BEGIN IMMEDIATE") # one writer at a time: no double alerts
cur = conn.execute("INSERT OR IGNORE INTO views VALUES (?,?,?,?)",
(evt["messageId"], domain, visit_key, now))
if cur.rowcount == 0:
conn.execute("COMMIT")
return {"status": "duplicate"}
since = now - WINDOW_DAYS * 86400
(count,) = conn.execute(
"SELECT COUNT(DISTINCT visit_key) FROM views WHERE domain=? AND ts>=?",
(domain, since)).fetchone()
recent = conn.execute("SELECT 1 FROM alerts WHERE domain=? AND ts>=?",
(domain, since)).fetchone()
fire = count >= THRESHOLD and not recent
if fire:
conn.execute("INSERT INTO alerts VALUES (?,?)", (domain, now))
conn.execute("COMMIT")
finally:
conn.close()
if fire:
background.add_task(post_to_slack, slack_blocks(target, count))
return {"status": "alerted" if fire else "counted", "count": count}simulate.py:
import hashlib, hmac, json, os, sys
import httpx
URL = os.environ.get("ROUTER_URL", "http://127.0.0.1:8000/events")
SECRET = os.environ.get("SEGMENT_SHARED_SECRET", "")
domain = sys.argv[1] if len(sys.argv) > 1 else "acme.com"
for visitor in ("v1", "v2", "v3", "v4"):
# The messageId is fixed per domain and visitor, so a second run is a replay.
evt = {"type": "page", "messageId": f"sim-{domain}-{visitor}", "anonymousId": visitor,
"properties": {"path": "/pricing", "company_domain": domain}}
body = json.dumps(evt, separators=(",", ":")).encode()
headers = {"content-type": "application/json"}
if SECRET:
headers["x-signature"] = hmac.new(SECRET.encode(), body, hashlib.sha1).hexdigest()
r = httpx.post(URL, content=body, headers=headers, timeout=5)
print(domain, visitor, "->", r.status_code, r.json())test_router.py:
import pytest
from fastapi.testclient import TestClient
import router
TARGETS = """domain,name,segment
acme.com,Acme Corp,enterprise
smallshop.io,Small Shop,smb
"""
@pytest.fixture()
def client(tmp_path, monkeypatch):
(tmp_path / "targets.csv").write_text(TARGETS, encoding="utf-8")
monkeypatch.setattr(router, "TARGETS_CSV", str(tmp_path / "targets.csv"))
monkeypatch.setattr(router, "DB_PATH", str(tmp_path / "t.db"))
monkeypatch.setattr(router, "SHARED_SECRET", "")
sent = []
monkeypatch.setattr(router, "post_to_slack", sent.append)
c = TestClient(router.app)
c.sent = sent
return c
def evt(mid, visitor, domain="acme.com", path="/pricing"):
return {"type": "page", "messageId": mid, "anonymousId": visitor,
"properties": {"path": path, "company_domain": domain}}
def post(client, *args, **kw):
return client.post("/events", json=evt(*args, **kw)).json()["status"]
def test_third_visitor_alerts_once_and_replays_are_ignored(client):
out = [post(client, f"m{i}", f"v{i}") for i in range(5)]
assert out == ["counted", "counted", "alerted", "counted", "counted"]
assert post(client, "m0", "v0") == "duplicate"
assert len(client.sent) == 1
def test_same_visitor_same_day_counts_once(client):
assert [post(client, f"m{i}", "v1") for i in range(5)] == ["counted"] * 5
def test_non_target_and_non_pricing_ignored(client):
assert post(client, "a", "v", "smallshop.io") == "not a target"
assert post(client, "b", "v", path="/blog") == "ignored"
def test_bad_signature_is_rejected(client, monkeypatch):
monkeypatch.setattr(router, "SHARED_SECRET", "s3cret")
assert client.post("/events", json=evt("x", "v")).status_code == 401
assert client.post("/events", json=evt("x", "v"),
headers={"x-signature": "bad"}).status_code == 401
def test_missing_message_id_is_400(client):
assert client.post("/events", json={"type": "page"}).status_code == 400No-budget path. You need no paid tool. The property company_domain is a name this course made up: it stands for the company a visitor-ID vendor would attach to the event. Real Segment events do not carry it. The simulator replaces the visitor-ID vendor, SQLite replaces Kafka, and an unset SLACK_WEBHOOK_URL prints the alert instead of sending it. To test real IP lookups, make a free IPinfo account, set IPINFO_TOKEN, remove company_domain from the simulator events and add "context": {"ip": "<a real office IP>"} to each event. The Lite API returns as_domain, the owner of the network block. That can be an internet provider rather than the employer, so the code skips a short list of provider domains. It is a rough stand-in for a paid database.
What done looks like. All five tests pass (a warning about httpx and starlette.testclient in the pytest output is harmless). simulate.py produces exactly one alert, on the third distinct visitor. A replayed messageId returns duplicate. A wrong or missing signature returns 401 when a secret is set. The Slack message shows a header, three fields, a button and a context line. You can say in one sentence what a "visit" means in your counter.
Stretch goal. Replace SQLite with Redis sorted sets (one per account, timestamp as score, trim with ZREMRANGEBYSCORE). Then add a second signal: a weekly CSV of surging accounts. Log which alerts reps acted on.
Common mistakes
- Counting page views instead of visits, so one person refreshing the pricing page fires an alert.
- Skipping dedup. At-least-once delivery means retries, and a retry doubles the count.
- Treating a company match as a person. "Someone at Acme" is not "the VP of Engineering".
- Alerting on every event after the threshold instead of the crossing, then wondering why sales muted the channel.
- Putting webhook URLs, shared secrets or tokens in the repo. Use environment variables and rotate anything that leaks.
- Ignoring consent, so the tracking script runs for EU visitors who never agreed.
- Skipping the signature check, so anyone who finds your endpoint URL can post fake events and trigger fake alerts.
Check yourself
1. Acme has three different visitors on your pricing page on Thursday, Friday and the next Monday. A counter using Monday-to-Sunday calendar weeks never alerts. Why?
Why: A fixed window cuts at its boundary, while a rolling 7-day window sees all three.
2. Segment retries a webhook that timed out, and your router receives the same event twice. What keeps the count correct?
Why: Delivery is at-least-once, so the endpoint must be idempotent, and a unique message id gives you that.
3. Which statement about the "5 minutes versus 30 minutes" lead-response statistic is most accurate?
Why: The figure covers form leads, and a secondary analysis says the HBR paper's fastest bucket was within an hour.
4. Why can a reverse-IP tool miss a real visit from a target account?
Why: Remote work, VPNs and shared networks break the link between an IP address and the employer.
5. You must load a visitor-identification script for EU visitors. What is the safest default?
Why: IP addresses can be personal data, and the site operator carries the duty, not the vendor.
How to learn this faster
Learn first: the event shape, idempotency and rolling windows. Skip for now: Kafka internals and vendor feature comparisons.
Drill: write five hostile test events by hand (duplicate id, refreshing visitor, wrong signature, non-target account, blog page). Predict the response before each run.
- Day 1: Read the Track spec and Slack webhook docs. Send one hand-written Block Kit message.
- Day 2: Build the receiver and the dedup table. Write the duplicate test first.
- Day 3: Add the rolling window and cooldown. Match the Thursday, Friday, Monday timeline to a test.
- Day 4: Add signature checks and the IPinfo Lite fallback. Test five known IPs.
- Day 5: Write a half-page note: what you collect, why, and how to delete it.
Resources
- Slack: Incoming webhooksDocsFree
The official reference for URLs, payloads, errors and limits.
- Segment: Track specDocsFree
The event shape most tools copy, with a full example payload.
- Segment: Webhooks destinationDocsFree
Signing, retries and the 5 second timeout, with verification code.
- Apache Kafka: IntroductionDocsFree
Clear definitions of event streaming, topics, partitions and retention.
- HubSpot: Using buyer intent dataDocsFreemium
How a major CRM does reverse-IP identification, with credit costs.
- IPinfo Lite APIDocsFree
Free unlimited country and ASN lookups for the no-budget path.
- 6sense 2025 B2B Buyer Experience ReportResearchFree
A large buyer survey on when buyers contact sellers. Written by an intent vendor.
- Common Room: Bombora topic intentDocsFree
What a surge score and weekly refresh look like inside a tool.
- UK ICO: Storage and access technologiesDocsFree
Regulator guidance on consent for cookies, pixels and fingerprinting.
Next module
Your router now knows which accounts are hot and tells a human. Stage 3, AI SDRs and Hyper-Personalization, uses those signals to decide what an AI-assisted message should say, and where a human must stay in the loop.
Built the project and passed the quiz? Mark the stage done. Your progress stays in this browser only.
Talk to Claude instead of typing to it.
Ducktate turns what you say into clean text, on your Mac, and pastes it anywhere.
Get early access