Integrations
Webhooks
Send each day's alert digest to your own systems: a CRM workflow, Zapier or Make, an internal service. The webhook gets the same digest as email and Slack, as structured JSON, signed so you can tell it came from Punchline.
Set one up
- Open Alerts → Where alerts go. You need to be an owner or admin of the site.
- Paste an
https://URL under Webhook (for your CRM) and save. - Copy the signing secret shown after saving. It's shown once; store it with your receiver's configuration.
- Press Send a test to deliver a sample digest and check the result.
To change the URL, paste a new one: that also issues a new secret. To stop, tick Remove.
The URL must be https, use a public host name (not an IP address or an internal name like .local or .internal), and be at most 500 characters. Put any token your receiver needs in the URL's path or query string, not as user:password@. Before every delivery Punchline checks the host still resolves only to public addresses, and it connects to the address it checked.
When it's sent
Alerts are checked once a day, shortly after 07:00 UTC, for the previous UTC day. If any alert fired, one digest goes to every destination: email, Slack and the webhook. Quiet days send nothing.
Each digest is sent once. There's no automatic retry: if your endpoint is down or doesn't answer 2xx within 10 seconds, that day's webhook delivery is lost, though the alerts are still on the Alerts page and in the email and Slack digests. Respond quickly, do slow work after responding, and use Send a test after changing your receiver.
The request
POST /your/path HTTP/1.1
Content-Type: application/json
User-Agent: Punchline-Webhook/1.0
Punchline-Signature: t=1791273600,v1=5f2c…
Redirects aren't followed: point the webhook at the final URL.
The payload
{
"type": "punchline.digest",
"subject": "Punchline: 2 alerts for shop.example on Oct 6",
"text": "Shop (shop.example), Oct 6 (UTC):\n\n• Agent share spike: Agent share: 46% on Oct 6, up 64% on the usual 28%.\n https://app.punchlineanalytics.com/dashboard?site=25cbb06c-e534-4f14-a740-785079ddc69c&range=30d\n• A company is researching you: A company's agents are researching you (7 days to Oct 6): acme.com (strong): agents read pricing, security and comparison pages 9 times.\n https://app.punchlineanalytics.com/dashboard/accounts?site=25cbb06c-e534-4f14-a740-785079ddc69c&range=30d\n\nManage alerts: https://app.punchlineanalytics.com/dashboard/alerts?site=25cbb06c-e534-4f14-a740-785079ddc69c\nOne digest a day, only when something fired.",
"data": {
"site": {
"id": "25cbb06c-e534-4f14-a740-785079ddc69c",
"name": "Shop",
"domain": "shop.example"
},
"day": "2026-10-06",
"alerts": [
{
"name": "Agent share spike",
"kind": "anomaly",
"summary": "Agent share: 46% on Oct 6, up 64% on the usual 28%.",
"report_url": "https://app.punchlineanalytics.com/dashboard?site=25cbb06c-e534-4f14-a740-785079ddc69c&range=30d",
"detail": {
"change_pct": 64
}
},
{
"name": "A company is researching you",
"kind": "account_signal",
"summary": "A company's agents are researching you (7 days to Oct 6): acme.com (strong): agents read pricing, security and comparison pages 9 times.",
"report_url": "https://app.punchlineanalytics.com/dashboard/accounts?site=25cbb06c-e534-4f14-a740-785079ddc69c&range=30d",
"detail": {
"accounts": [
{
"company_domain": "acme.com",
"strength": "strong",
"score": 23,
"summary": "Agents read pricing, security and comparison pages 9 times",
"agent_sessions": 4,
"operators": [
"openai",
"anthropic"
],
"intents": [
{
"intent": "pricing",
"agent_pageviews": 4
},
{
"intent": "security",
"agent_pageviews": 3
},
{
"intent": "comparison",
"agent_pageviews": 2
}
],
"last_seen": "2026-10-06"
}
]
}
}
]
}
}
| Field | |
|---|---|
type |
Always punchline.digest for now. Check it, and ignore types you don't handle. |
subject, text |
The same subject and text as the email, for posting somewhere as is |
data.site |
The site's ID, name and domain |
data.day |
The UTC day the alerts are about |
data.alerts[] |
One entry per alert that fired: its name, kind (threshold, anomaly, new_operator, account_signal), a one-line summary and a report_url to the report behind it |
data.alerts[].detail |
Structured detail, when the kind has some. anomaly: change_pct, the change from the usual. new_operator: operators, the new operators and the agents they used. account_signal: accounts, each company's domain, strength, score, summary, agent sessions, operators, top intents and the last day seen. |
New fields may be added; ignore ones you don't know.
Verify the signature
Punchline-Signature is t=<unix seconds>,v1=<signature>, where the signature is the hex HMAC-SHA256 of <t>.<raw body> using your signing secret. Check it against the raw request body (before any JSON parsing), compare in constant time, and reject timestamps more than 5 minutes old so a captured request can't be replayed.
Node.js:
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyPunchline(header, rawBody, secret, toleranceSec = 300) {
const parts = Object.fromEntries(String(header ?? "").split(",").map((p) => p.split("=", 2)));
const t = Number(parts.t);
if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
const given = String(parts.v1 ?? "");
return given.length === expected.length && timingSafeEqual(Buffer.from(given), Buffer.from(expected));
}
With Express, read the raw body for this route:
app.post("/hooks/punchline", express.raw({ type: "application/json" }), (req, res) => {
const raw = req.body.toString("utf8");
if (!verifyPunchline(req.get("punchline-signature"), raw, process.env.PUNCHLINE_WEBHOOK_SECRET)) return res.sendStatus(401);
res.sendStatus(204); // answer first, then do the work
handleDigest(JSON.parse(raw));
});
Python:
import hashlib, hmac, time
def verify_punchline(header: str, raw_body: bytes, secret: str, tolerance: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in (header or "").split(",") if "=" in p)
try:
t = int(parts["t"])
except (KeyError, ValueError):
return False
if abs(time.time() - t) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))
Recipes
HubSpot or Salesforce activity per company
For CRM write-back without a webhook, connect HubSpot or Salesforce directly: Punchline adds a note or task to the matching company. Use the webhook when you want your own logic, such as routing by territory or creating a task for the account owner:
- Add the A company is researching you alert.
- In your workflow tool, receive the webhook, verify it, and loop over
data.alerts[]wherekindisaccount_signal. - For each item in
detail.accounts, look up the company bycompany_domainand create an activity withsummaryand thereport_url.
Zapier or Make
Use a Catch Hook (Zapier) or Custom webhook (Make) trigger. These tools can't check signatures, so put a long random token in the webhook URL's path and treat the URL as a secret. Split data.alerts into items to handle each alert separately.
Post somewhere other than Slack
subject and text are ready to post as they are: send them to Microsoft Teams, Discord or an internal chat with a few lines of code.
Privacy
Digests carry totals and summaries for each alert's own condition, operator and agent names, and for account signals the company's domain. Never paths of individual visits, sessions or anything about one person. Text that comes from traffic is cleaned of control characters before it's sent.
Ask the docs
Answers come from these docs only, written by Claude (Anthropic). Your question is sent to answer it, and isn't stored.