A 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, and that is the difference between a signal being interesting and a signal becoming pipeline.
This is the full integration. Everything here is copy-pasteable, and every design decision has its reasoning attached, because the reasoning is what tells you whether you can safely deviate from it.
The five events
| Event | Fires | Carries |
|---|---|---|
signal.created | Once per signal, the first time it is seen | The signal plus its listener |
run.completed | After every signal.created for that run | Searched and kept counts |
run.failed | A run threw | The error message |
signal.urgent | A signal cleared the workspace's urgency bar | The whole signal.created body plus the threshold, level and score |
pipeline.stage_changed | Somebody moved a deal | The entry, where it moved from, who moved it, and enough of the signal to read it |
Three things about that table are load-bearing.
signal.urgent is a strict superset of signal.created, and every urgent signal fires both. This looks redundant until you have built the alternative. A subscriber that already parses signals needs no second parser. An endpoint that only wants the interruptions does not have to subscribe to a firehose it will discard 95% of. And the threshold travels in the body, so your downstream rules do not have to re-derive a decision the workspace already made and get a different answer.
The two events carry different ids for the same signal. One occurrence under two events is still two deliveries, and giving them a shared id would make your dedupe layer drop one of them as a duplicate.
signal.created fires on insert only. The run loop upserts, so a signal seen on an earlier run is refreshed rather than created. Firing on every upsert would re-notify you about the same Reddit post every six hours, forever, which is the single most common failure mode of home-built polling scrapers, and the reason people stop trusting the channel.
pipeline.stage_changed is dispatched from the API, not the worker, because a person caused it. It is never awaited: a subscriber whose endpoint hangs must not make somebody's drag-and-drop take thirty seconds, and must never fail it: the deal moved, and a third party being down does not make that untrue.
Registering an endpoint
curl -X POST https://openpulse.cloud/v1/webhooks \
-H "Authorization: Bearer op_live_..." \
-H "Content-Type: application/json" \
-d '{
"url": "https://hooks.yourcompany.com/openpulse",
"events": ["signal.created", "signal.urgent", "run.failed"],
"description": "Signals → Slack + HubSpot"
}'The full surface:
GET /v1/webhooks # any member
POST /v1/webhooks # admin { url, events[], description? }
PATCH /v1/webhooks/:id # admin { url?, events?, enabled?, description? }
DELETE /v1/webhooks/:id # admin
POST /v1/webhooks/:id/test # admin, sends a sample event now
GET /v1/webhooks/:id/deliveries # last 20 attempts, with request and responseReads are open to any member; every write is admin-only, because adding an endpoint is adding an egress path for the workspace's data. That is a different class of action from reading a table.
POST /v1/webhooks/:id/test sends a real sample of the event you ask for, through the real delivery path, with a real signature. Use it to build your receiver before a run has ever produced a signal.
A note on that endpoint, because the bug it had is instructive. The samples used to be a chain of
ifs ending in an unguardedreturnof therun.failedbody, so a newly added event silently delivered a run failure to anyone pressing "Send test". Theit.each(WEBHOOK_EVENTS)test passed the entire time, because it only asserted the result was truthy. The samples are now aRecord<WebhookEvent, …>, so a missing sample is a compile error, and a test asserts every event's sample is distinct. Truthiness is not an assertion.
Verifying a delivery
Every request carries four headers:
x-openpulse-event signal.created
x-openpulse-delivery evt_… ← stable across retries; dedupe on this
x-openpulse-timestamp 1760000000
x-openpulse-signature sha256=…The signature covers "<timestamp>.<raw body>", not the body alone.
That distinction is the whole security property. Signing only the body makes every delivery replayable forever: anyone who captures one valid request (a log aggregator, a proxy, a misconfigured APM) can resend it a thousand times and each copy verifies, because nothing in the signed material says when it was made. Including the timestamp inside the signed string, and rejecting anything older than five minutes, closes that.
A complete receiver
import crypto from "node:crypto";
const SECRET = process.env.OPENPULSE_WEBHOOK_SECRET!;
const TOLERANCE_SECONDS = 300;
interface Verified {
event: string;
deliveryId: string;
body: unknown;
}
/**
* Returns the parsed event, or null if the request is not authentic.
* `raw` must be the RAW request body: the exact bytes, before any JSON parse
* and before any framework middleware has re-serialised it.
*/
export function verify(headers: Headers, raw: string): Verified | null {
const ts = headers.get("x-openpulse-timestamp");
const sig = headers.get("x-openpulse-signature");
const event = headers.get("x-openpulse-event");
const id = headers.get("x-openpulse-delivery");
if (!ts || !sig || !event || !id) return null;
// Replay window first: it is the cheapest check and it fails most attacks.
const age = Math.abs(Date.now() / 1000 - Number(ts));
if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) return null;
const expected = "sha256=" + crypto
.createHmac("sha256", SECRET)
.update(`${ts}.${raw}`)
.digest("hex");
// timingSafeEqual throws on a length mismatch, so guard it.
const a = Buffer.from(sig);
const b = Buffer.from(expected);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null;
return { event, deliveryId: id, body: JSON.parse(raw) };
}Three mistakes this avoids, all of which produce a receiver that works in testing and is insecure in production:
Comparing with ===. String comparison short-circuits on the first differing byte, which leaks the correct prefix through timing. Use timingSafeEqual, and guard the length first, because it throws rather than returning false on a mismatch.
Re-serialising the body. JSON.stringify(JSON.parse(raw)) is not raw. Key order, whitespace and unicode escaping all differ, and the HMAC will not match. In Next.js App Router use await req.text(). In Express you need express.raw({ type: "application/json" }) on this route specifically, not the global express.json().
Skipping the timestamp check because the signature "already proves it's us." It proves who, not when.
Framework wiring
Next.js App Router, app/api/openpulse/route.ts:
export async function POST(req: Request) {
const raw = await req.text();
const evt = verify(req.headers, raw);
if (!evt) return new Response("unauthorised", { status: 401 });
if (await alreadyHandled(evt.deliveryId)) return new Response("ok");
await handle(evt); // see routing, below
await remember(evt.deliveryId);
return new Response("ok"); // 2xx fast; do slow work out of band
}Express:
app.post(
"/openpulse",
express.raw({ type: "application/json" }),
(req, res) => {
const evt = verify(new Headers(req.headers), req.body.toString("utf8"));
if (!evt) return res.status(401).send("unauthorised");
res.status(200).send("ok"); // acknowledge first
queue.add("openpulse", evt); // then work
},
);Idempotency: dedupe on the delivery id
x-openpulse-delivery is stable across retries and unique per event. Store it with a TTL comfortably longer than the retry window: an hour is plenty; a day is safer if your handler is expensive or user-visible.
const SEEN_TTL = 60 * 60 * 24;
async function alreadyHandled(id: string) {
// SET NX returns null when the key already exists.
return (await redis.set(`op:evt:${id}`, "1", "EX", SEEN_TTL, "NX")) === null;
}Do not dedupe on the signal id. An urgent signal legitimately arrives twice (once as signal.created, once as signal.urgent) and those are two events you probably want to handle differently. Deduping on the signal id silently drops one of them, and which one you drop depends on delivery order, which is not guaranteed.
Retries, and why they live inside the job
Three attempts, backing off 500ms then 2s, with an 8-second timeout each.
429or5xx→ retried.4xx→ not retried. That is your server understanding us and saying no. Repeating it produces three identical failures and delays the real answer, which is in your logs.
Retries happen inside one job, not by requeueing it. This is subtle and it matters to you as a subscriber: a queue-level retry would re-run the job that builds the payload, minting a new event id, so you would see two events for one signal rather than two attempts at one, and your dedupe key would be useless. Keeping retries inside the job keeps the idempotency key stable.
Delivery is best-effort throughout. A subscriber whose endpoint is down is a problem visible in their delivery log, never a reason to fail the run, which would make the queue redo the entire search and reclassification.
The delivery log keeps request and response bodies for seven days (a TTL index), which is what makes a failing endpoint debuggable rather than a black box. When someone says "we stopped getting signals last Tuesday", GET /v1/webhooks/:id/deliveries is the answer.
Routing events to real destinations
async function handle({ event, body }: Verified) {
switch (event) {
case "signal.urgent": return notifySlack(body);
case "signal.created": return maybeCreateCrmRecord(body);
case "run.failed": return pageOncall(body);
case "pipeline.stage_changed": return syncDealStage(body);
default: return; // unknown events are not errors
}
}That default is deliberate. New events get added; a receiver that throws on one it does not recognise turns a feature release into a 4xx storm and a disabled endpoint.
Slack, for the interruptions
async function notifySlack(evt: any) {
const { signal, score, level, threshold } = evt.data;
await fetch(process.env.SLACK_WEBHOOK_URL!, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
blocks: [
{ type: "section", text: { type: "mrkdwn",
text: `*<${signal.url}|${signal.title}>*\n> ${signal.excerpt}` } },
{ type: "context", elements: [{ type: "mrkdwn",
text: `${signal.source} · ${signal.classification} · score ${score}/100 ` +
`(bar: ${threshold}) · ${level} · ${signal.inferenceHops === 0
? "stated" : "inferred, 1 hop"}` }] },
],
}),
});
}Put inferenceHops in the message. It is the difference between "they said they need this" and "we worked out they probably need this", and a salesperson opens those two with different opening lines.
Subscribe this handler to signal.urgent only, not signal.created. The whole value of the urgency threshold is that somebody already decided what deserves an interruption; re-filtering a firehose in your own handler throws that away and puts the rule in two places.
HubSpot, for the record
async function maybeCreateCrmRecord(evt: any) {
const s = evt.data.signal;
if (s.score < 70) return; // your bar, not ours
if (!s.accountKey) return; // no company resolved, nothing to attach to
await fetch("https://api.hubapi.com/crm/v3/objects/companies", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.HUBSPOT_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
properties: {
domain: s.accountKey.replace(/^domain:/, ""),
openpulse_signal_url: s.url,
openpulse_score: String(s.score),
openpulse_angle: s.suggestedAngle,
},
}),
});
}Two notes. accountKey is absent on most market-mode signals: the poster is a pseudonymous forum user and there is no company to resolve. Guard for it rather than writing rows with a null domain. And suggestedAngle is one line on how to reply, computed by the same classification call, which is genuinely the most useful thing to put in a CRM note.
If you would rather not write this code at all, GET /v1/exports/pipeline.csv?format=crm-hubspot produces a file whose headers are HubSpot's own import strings. They are HubSpot's strings, not ours, and changing one to read better is exactly what turns a one-click import into a twenty-field mapping exercise.
Pulling history: API keys
Webhooks tell you what is new. The API tells you what is there.
curl -H "Authorization: Bearer op_live_..." \
"https://openpulse.cloud/v1/results?listenerId=lst_...&maxAgeDays=30&limit=100"{ "items": [ /* … */ ], "nextCursor": "eyJzIjoi…" }Pass ?cursor= to continue. Cursors are opaque keyset markers over (sort field, id), so pages never repeat or skip a row while new documents arrive behind you, which offset pagination cannot promise on a collection that is being written to. ?limit= defaults to 50 and caps at 200. GET /v1/results/stats returns the window-wide counts, since those cannot be derived from a single page.
The two security properties to understand before you mint a key
A key is always a member, never an admin. Enforced at the one place the auth context is built, rather than by convention:
request.authContext = {
userId: `apikey:${key.id}`,
orgId: key.orgId,
orgRole: "org:member", // always. Never the creator's real role.
};A key minted by an admin cannot create or run a listener (which spends money), delete a competitor, change billing, or mint another key: all of those sit behind an admin check that reads that field. A "convenience" that inherited the creator's role would turn every leaked key into a full account takeover.
The actor is recorded as apikey:<id>, not as the person who created it, so a leak is distinguishable from that person's own activity in the audit log.
Scopes are deny-by-default. A route is reachable by a key only if it declares a scope:
app.get("/v1/results", { config: scopedTo("signals:read") }, handler);Enforcement is a single global onRequest hook, not per-route middleware, and that is a security property rather than a style preference. This was built the wrong way round first: with per-route middleware, only the routes somebody remembered to annotate were protected, so /v1/runs, /v1/workspace/settings and /v1/drafts/quota all answered 200 to a key scoped to nothing but signals:read. No unit test could catch it, because a test only exercises routes somebody wrote. It was found by pointing a real key at the running API and asking for things it had no business reading. Deny-by-default makes a forgotten declaration fail closed: the route is simply unreachable.
There is no scope that grants admin actions, and adding one would quietly undo the paragraph above.
Key handling
The op_live_ prefix is load-bearing in two directions. It lets the auth hook decide a bearer token is an API key without parsing it, which is what leaves the dashboard's Clerk session tokens and the MCP endpoint's OAuth tokens untouched, since neither is ever op_live_-prefixed. It is also what secret scanners match on when a key gets committed to a repository.
Only the sha256 of a key is stored, so a lost secret is replaced rather than recovered. That is sha256 rather than bcrypt or argon2 deliberately: slow KDFs exist to make guessing slow, and a 256-bit CSPRNG secret is not guessable at any speed: a slow hash would add ~100ms of CPU to every request to defend against an attack that cannot happen.
Keys are cached in Redis for 60 seconds, with negative lookups cached too (so a bot spraying invalid keys does not cost a database read per attempt). Revocation deletes the cache entry, so it takes effect immediately rather than within the minute. The cached hash is re-compared on read rather than trusted, so a poisoned cache cannot become an authentication bypass. If Redis is down, everything still works and simply reads Mongo.
Revoked keys are marked, never deleted (the record that a key existed is exactly what you need when working out what it touched) and the live-key allowance only counts unrevoked keys, so rotation is not punished.
A production checklist
- Raw body preserved end to end. Test by round-tripping a payload with a unicode character in it.
- Timestamp tolerance enforced (300s) and your server clock is NTP-synced. A 10-minute clock skew rejects every delivery.
timingSafeEqualwith a length guard, not===.- Dedupe on
x-openpulse-delivery, not on the signal id. - Return 2xx quickly; do slow work on a queue. You have 8 seconds.
- Unknown events return 2xx and are ignored.
4xxfrom you is final: make sure a transient failure returns5xxinstead, or you get one attempt rather than three.- Subscribe to
run.failedand route it somewhere a human reads. A silently failing listener looks identical to a quiet market. - One key per integration, so revoking one does not break the others.
- Alert on your own handler's error rate.
GET /v1/webhooks/:id/deliveriesis our side of the story; your logs are the other half.
Plans
Webhook endpoints: 1 on Go ($49/mo), unlimited on Pro ($499/mo) and Enterprise. API keys: 2 on Go, 10 on Pro, unlimited on Enterprise. CRM export formats are Pro. Both plans open with a 14-day trial that takes a card up front.
In-app documentation for the API, webhooks and exports lives under Settings → Docs, and the scope and event tables there are generated from the same constants the server enforces, so a scope that exists but is undocumented is impossible, and a test asserts the tables and the constants agree.
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.