Docs
Webhooks
Add a Custom webhook channel under Settings → Delivery in the app. When a report is ready, we
POST it as JSON to your URL, signed with a secret that only you and we know. Slack channels use Slack’s own
message format and are not covered here.
Event types
| Event | When |
|---|---|
report.generated | A report with at least one change was generated for your workspace and is being delivered to the channel. |
report.test | You clicked Send test on the channel. Contains static sample data with a different shape (see below). |
Switch on the event field and ignore events you don’t recognise; more may be added.
Request
POST /your/endpoint HTTP/1.1
Content-Type: application/json
User-Agent: CompetitorMove-Webhooks/1.0
X-CompetitorMove-Signature: t=1791190800,v1=5f2b9c0e8d1a…(64 hex chars) - Method
POST, body is UTF-8 JSON (compact, no trailing newline). -
X-CompetitorMove-Signatureis sent when the channel has a signing secret. Channels created before signing shipped may not have one — rotate the secret in Settings to start signing.
Payload: report.generated
The envelope:
| Field | Type | Description |
|---|---|---|
event | string | Always "report.generated". |
report | object | The report and its changes (below). |
report_url | string | Link to the report in the dashboard (requires sign-in). |
generated_at | string (ISO 8601) | When this delivery payload was built, i.e. send time. The report’s creation time is report.generated_at. |
The report object
| Field | Type | Description |
|---|---|---|
id | uuid | Stable report id. Use it to de-duplicate. |
org_id | uuid | Your workspace id. |
period_start, period_end | date (YYYY-MM-DD) | Reporting window, both inclusive. |
generated_at | string (ISO 8601) | When the report was generated. |
insights.title | string | Report headline. |
insights.monitoring_since | string | Date monitoring began for this workspace. |
insights.why_it_matters | string | AI-written summary of why the changes matter. |
insights.tip | string | A suggested next step. |
changes | array | Change objects, newest detected_at first. |
Change objects
| Field | Type | Description |
|---|---|---|
id | uuid | Change id. |
competitor_id | uuid | Competitor the change belongs to. |
scan_id | uuid | null | Scan that confirmed the change. Becomes null once old scans are pruned. |
type | string | "pricing", "messaging", "pages" or "changelog". Determines the shape of payload. |
title | string | Short human-readable summary. |
description | string | null | Longer explanation. |
priority | string | "high", "medium" or "low". |
detected_at | string (ISO 8601) | When the change was confirmed. |
first_seen_at, last_unchanged_at | string | null | When the new value was first seen and the old value last seen. Null on older changes. |
payload | object | Type-specific details (below). |
competitor | object | id, domain, display_name, avatar_initials (string | null). |
Change objects may gain additional fields over time; ignore fields you don’t use. A change is only reported after two
consecutive scans agree, so detected_at is typically about a day after the page actually changed.
Example
{
"event": "report.generated",
"report_url": "https://app.competitormove.com/reports/7d2f4a1e-3b8c-4f6a-9e21-0c5d8b7a6f13",
"generated_at": "2026-10-12T07:00:04.512Z",
"report": {
"id": "7d2f4a1e-3b8c-4f6a-9e21-0c5d8b7a6f13",
"org_id": "0b6c2e9a-41d7-4c55-8f0e-2a9d3c7b1e48",
"period_start": "2026-10-05",
"period_end": "2026-10-11",
"generated_at": "2026-10-12T06:00:12.031Z",
"insights": {
"title": "Rival raises Pro pricing and ships SSO",
"monitoring_since": "2026-09-14",
"why_it_matters": "A higher Pro price narrows the gap with your Team plan…",
"tip": "Revisit your pricing page comparison table this week."
},
"changes": [
{
"id": "c1a7e0d2-9f4b-4e3a-8c61-5b2d7e9f0a14",
"competitor_id": "5e9d1c3b-7a2f-4b8e-9d60-1f4c2a8e7b35",
"scan_id": "a3f8c2d1-6e4b-4a9c-8f17-2d5e9b0c7a46",
"type": "pricing",
"title": "Pro plan price increased",
"description": "Pro went from $49 to $79 per month (billed monthly).",
"priority": "high",
"detected_at": "2026-10-09T03:04:51.220Z",
"first_seen_at": "2026-10-08T03:05:10.004Z",
"last_unchanged_at": "2026-10-07T03:02:44.871Z",
"payload": {
"before": [
{
"tier": "Pro",
"price": "$49/mo",
"price_kind": "fixed",
"prices": [
{ "amount": 49, "currency": "USD", "period": "month", "billing": "monthly" }
],
"cta": "Start free trial"
}
],
"after": [
{
"tier": "Pro",
"price": "$79/mo",
"price_kind": "fixed",
"prices": [
{ "amount": 79, "currency": "USD", "period": "month", "billing": "monthly" }
],
"cta": "Start free trial"
}
]
},
"competitor": {
"id": "5e9d1c3b-7a2f-4b8e-9d60-1f4c2a8e7b35",
"domain": "rival.example",
"display_name": "Rival",
"avatar_initials": "RI"
}
}
]
}
} Payload by change type
pricing
{ "before": PricingTier[], "after": PricingTier[] } — the full tier list before and after (see the example
above). Each PricingTier:
| Field | Type | Description |
|---|---|---|
tier | string | Plan name as shown on the page. |
price | string | Human-readable headline price, e.g. "$79/mo" or "Contact sales". |
note | string, optional | Extra context from the page. |
price_kind | string, optional | "fixed", "free", "custom" (contact sales) or "usage". |
prices | array, optional | { amount: number, currency: string | null, period: "month" | "year" | "one_time" | "other", billing: "monthly" | "annual" | "unspecified" } |
cta | string | null, optional | The tier’s call-to-action label. |
Optional fields can be missing on older data; treat missing as unknown.
messaging
Homepage headline and/or primary call to action. Each key is present only if that element changed.
"type": "messaging",
"payload": {
"headline": { "before": "Analytics for growing teams", "after": "The analytics platform for enterprises" },
"cta": { "before": "Start free", "after": "Book a demo" }
}
pages
New URLs discovered in the competitor’s sitemap.
"type": "pages",
"payload": {
"pages": [
"https://rival.example/features/sso",
"https://rival.example/solutions/enterprise"
]
}
changelog
New entries on the competitor’s changelog or release-notes page: the page URL and the new entry headings, in page order (usually newest first).
"type": "changelog",
"payload": {
"url": "https://rival.example/changelog",
"entries": [
"SAML single sign-on is now available",
"New usage-based billing for API calls"
]
}
Payload: report.test
Sent by Send test. It is signed like a real delivery but contains fixed sample data in a simplified shape —
don’t use it to build your parser. There is no report_url or generated_at; the send time is in
delivered_at.
{
"event": "report.test",
"delivered_at": "2026-10-08T14:21:09.338Z",
"report": {
"title": "Weekly Intelligence Brief — sample",
"period": "Mar 1 – Mar 7, 2026",
"competitor": "Acme Analytics",
"changes": [
{ "type": "pricing", "title": "Pricing page updated", "priority": "high" },
{ "type": "messaging", "title": "Homepage headline changed", "priority": "medium" },
{ "type": "pages", "title": "3 new pages discovered", "priority": "medium" }
]
}
}
Verifying signatures
The header has the form t=<unix seconds>,v1=<hex>. v1 is the hex-encoded
HMAC-SHA256 of the string `${t}.${body}` — the timestamp, a literal dot, then the exact raw request body —
keyed with your channel’s secret. The key is the whole secret string as shown in Settings, including the
whsec_ prefix.
signed_payload = "{t}.{raw_request_body}"
expected = hex( HMAC_SHA256( key = "whsec_…", message = signed_payload ) )
valid = constant_time_equal(expected, v1) and |now - t| <= 300
- Read the raw body before parsing JSON. Re-serialising parsed JSON changes the bytes and the signature won’t match.
- Compare with a constant-time function, not
==. - Reject timestamps more than 5 minutes from your clock to block replayed requests.
- Respond
400 on failure. Rotate the secret in Settings if it may have leaked; the old one stops working immediately.
Node.js
import crypto from 'node:crypto';
import express from 'express';
// The full secret from Settings, including the "whsec_" prefix.
const SECRET = process.env.COMPETITORMOVE_WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 5 * 60;
export function verifyCompetitorMoveSignature(rawBody, header, secret, toleranceSeconds = TOLERANCE_SECONDS) {
if (!header || !secret) return false;
const parts = {};
for (const piece of header.split(',')) {
const i = piece.indexOf('=');
if (i > 0) parts[piece.slice(0, i).trim()] = piece.slice(i + 1).trim();
}
const t = Number(parts.t);
const v1 = parts.v1;
if (!Number.isInteger(t) || !v1) return false;
// Reject stale (or future) timestamps to block replays.
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - t) > toleranceSeconds) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${t}.${rawBody}`)
.digest('hex');
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(v1, 'hex');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
const app = express();
// Use the raw body: re-serialising parsed JSON changes bytes and breaks the HMAC.
app.post('/webhooks/competitor-move', express.raw({ type: 'application/json' }), (req, res) => {
const rawBody = req.body.toString('utf8');
const header = req.get('X-CompetitorMove-Signature');
if (!verifyCompetitorMoveSignature(rawBody, header, SECRET)) {
return res.status(400).send('Invalid signature');
}
const event = JSON.parse(rawBody);
res.sendStatus(204); // acknowledge quickly; do heavy work asynchronously
if (event.event === 'report.generated') {
// De-duplicate on event.report.id — a report can be re-sent manually.
for (const change of event.report.changes) {
console.log(change.competitor.display_name, change.type, change.title);
}
}
});
app.listen(3000);
Python
import hashlib
import hmac
import json
import os
import time
from flask import Flask, abort, request
# The full secret from Settings, including the "whsec_" prefix.
SECRET = os.environ["COMPETITORMOVE_WEBHOOK_SECRET"]
TOLERANCE_SECONDS = 5 * 60
def verify_competitor_move_signature(raw_body: bytes, header: str | None, secret: str,
tolerance: int = TOLERANCE_SECONDS) -> bool:
if not header or not secret:
return False
parts = {}
for piece in header.split(","):
key, sep, value = piece.partition("=")
if sep:
parts[key.strip()] = value.strip()
try:
t = int(parts["t"])
v1 = parts["v1"]
except (KeyError, ValueError):
return False
# Reject stale (or future) timestamps to block replays.
if abs(int(time.time()) - t) > tolerance:
return False
signed_payload = f"{t}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed_payload, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)
app = Flask(__name__)
@app.post("/webhooks/competitor-move")
def competitor_move_webhook():
raw_body = request.get_data() # raw bytes, before any JSON parsing
header = request.headers.get("X-CompetitorMove-Signature")
if not verify_competitor_move_signature(raw_body, header, SECRET):
abort(400)
event = json.loads(raw_body)
if event["event"] == "report.generated":
# De-duplicate on event["report"]["id"] — a report can be re-sent manually.
for change in event["report"]["changes"]:
print(change["competitor"]["display_name"], change["type"], change["title"])
return "", 204
Delivery and retries
- When: reports are generated weekly (Mondays, around 06:00 UTC) when there were changes in the period, or
on demand with Generate now on the Reports page. Deliveries go out in a daily run around 07:00 UTC. Scheduled times
are approximate.
- Success means your endpoint returned a
2xx status within 10 seconds. Your response body is
ignored.
- Once per channel: each report is delivered successfully at most once per channel by the scheduled run.
If a delivery fails, it is retried on later daily runs for up to 72 hours after the report was
generated, then dropped. Delivery status per channel is shown on the report page.
- Manual re-sends: a report re-sent manually is deliberately delivered again, so de-duplicate on
report.id.
- HTTPS only. The URL must be
https:// on the default port and resolve to a public IP address. - Redirects are not followed. A
3xx response counts as a failure — configure the final URL.
- Any other non-
2xx status, a timeout or a connection error is a failure.
Instant pricing alerts are email-only. When a pricing change is confirmed, we also send an instant alert
email without waiting for the weekly report. These alerts are not sent to webhooks or Slack; the change appears in
the next report.generated payload.
Questions
Email hello@competitormove.com. Security issues: security@competitormove.com
— see our Security page.