Competitor Move

Docs

Webhooks

Send Competitor Move reports to n8n, Zapier, your CRM or your own service.

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

EventWhen
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-Signature is 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:

FieldTypeDescription
eventstringAlways "report.generated".
reportobjectThe report and its changes (below).
report_urlstringLink to the report in the dashboard (requires sign-in).
generated_atstring (ISO 8601)When this delivery payload was built, i.e. send time. The report’s creation time is report.generated_at.

The report object

FieldTypeDescription
iduuidStable report id. Use it to de-duplicate.
org_iduuidYour workspace id.
period_start, period_enddate (YYYY-MM-DD)Reporting window, both inclusive.
generated_atstring (ISO 8601)When the report was generated.
insights.titlestringReport headline.
insights.monitoring_sincestringDate monitoring began for this workspace.
insights.why_it_mattersstringAI-written summary of why the changes matter.
insights.tipstringA suggested next step.
changesarrayChange objects, newest detected_at first.

Change objects

FieldTypeDescription
iduuidChange id.
competitor_iduuidCompetitor the change belongs to.
scan_iduuid | nullScan that confirmed the change. Becomes null once old scans are pruned.
typestring"pricing", "messaging", "pages" or "changelog". Determines the shape of payload.
titlestringShort human-readable summary.
descriptionstring | nullLonger explanation.
prioritystring"high", "medium" or "low".
detected_atstring (ISO 8601)When the change was confirmed.
first_seen_at, last_unchanged_atstring | nullWhen the new value was first seen and the old value last seen. Null on older changes.
payloadobjectType-specific details (below).
competitorobjectid, 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:

FieldTypeDescription
tierstringPlan name as shown on the page.
pricestringHuman-readable headline price, e.g. "$79/mo" or "Contact sales".
notestring, optionalExtra context from the page.
price_kindstring, optional"fixed", "free", "custom" (contact sales) or "usage".
pricesarray, optional{ amount: number, currency: string | null, period: "month" | "year" | "one_time" | "other", billing: "monthly" | "annual" | "unspecified" }
ctastring | null, optionalThe 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
  1. Read the raw body before parsing JSON. Re-serialising parsed JSON changes the bytes and the signature won’t match.
  2. Compare with a constant-time function, not ==.
  3. Reject timestamps more than 5 minutes from your clock to block replayed requests.
  4. 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.