Webhooks

Webhook Events

Subscribe to opportunity lifecycle, verification, and outcome events as HMAC-signed POSTs.

Quick start

  1. Generate a webhook in Signal360 → Settings → Webhooks. Pick events, save.
  2. Copy the secret once at creation. Store it in your environment.
  3. Build a public POST endpoint that validates the signature (examples below).
  4. Return any 2xx within 10s on success. Return non-2xx (or time out) and we retry: 1 min, 5 min, 30 min, then give up.

Sample payload

Every event uses the same envelope: event, occurredAt, organizationId, data.

{
  "event": "opportunity.resolved",
  "occurredAt": "2026-04-30T18:42:11.847Z",
  "organizationId": 42,
  "data": {
    "opportunity": {
      "id": 1234,
      "brandId": 7,
      "type": "missing_citation",
      "severity": "high",
      "status": "resolved",
      "title": "techcrunch.com cites competitors - you are absent (consensus 78.1)",
      "openedAt": "2026-04-15T09:12:00.000Z",
      "resolvedAt": "2026-04-30T18:42:11.000Z",
      "externalTicketProvider": "jira",
      "externalTicketKey": "OPS-1042"
    }
  }
}

Headers

HeaderValue
X-Signal360-Signaturesha256=<hex> - HMAC-SHA256 of the raw body using your webhook secret
X-Signal360-Evente.g. opportunity.resolved
X-Signal360-Delivery-Attempt1, 2, or 3 - increments on retries
User-AgentSignal360-Webhook/1.0

Verifying the signature

Use a constant-time comparison. Sign the raw body bytes - not the parsed JSON.

Node.js (Express)

import crypto from "node:crypto";

app.post("/webhooks/signal360", express.raw({ type: "application/json" }), (req, res) => {
  const expected = "sha256=" + crypto
    .createHmac("sha256", process.env.SIGNAL360_WEBHOOK_SECRET)
    .update(req.body) // raw bytes
    .digest("hex");
  const sig = req.header("X-Signal360-Signature");
  const ok = sig
    && sig.length === expected.length
    && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
  if (!ok) return res.sendStatus(401);
  // Process the event...
  res.sendStatus(200);
});

Python

import hmac, hashlib

def verify(secret: str, raw_body: bytes, signature_header: str) -> bool:
    expected = "sha256=" + hmac.new(
        secret.encode(), raw_body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, signature_header or "")

Events

opportunity.opened

When a new opportunity is detected and persisted via OpportunityService.open() - first time only per dedupeKey.

FieldTypeDescription
opportunityOpportunityFull opportunity record
opportunity.assigned

When an opportunity transitions into `assigned` status - typically when a user assigns an owner.

FieldTypeDescription
opportunityOpportunityUpdated opportunity record
fromStatusstringPrevious status
toStatusstringAlways `assigned`
opportunity.resolved

When verification (LLM-panel, re-crawl, or time-window) confirms the fix worked.

FieldTypeDescription
opportunityOpportunityResolved opportunity
opportunity.regressed

When verification fails - the fix did not resolve the issue.

FieldTypeDescription
opportunityOpportunityRegressed opportunity
verification.completed

When any verification run reaches a terminal state (pass / fail / inconclusive).

FieldTypeDescription
opportunityIdintegerOpportunity the verification ran for
result"pass" | "fail" | "inconclusive"Verification outcome
verificationOpportunityVerification | undefinedThe verification record (if available)
outcome.finalized

When a Phase B outcome row receives its post-window measurement (typically 30-60 days after resolution).

FieldTypeDescription
opportunityIdintegerSource opportunity
outcomeIdintegerOutcome record id
metricKindstringcitation_rate | sov | sentiment | ai_referral_traffic | conversions | revenue
deltanumber | nullAbsolute change after - before

Retry policy

  • 2xx response within 10s → success, no retries.
  • 5xx response or network error → retry at 1 min, 5 min, 30 min. After 3 attempts, the delivery is marked failed and you can replay it from Settings → Webhooks → Test.
  • 4xx response → terminal. We assume your endpoint rejected the payload intentionally and won't retry.
  • Every attempt is recorded in webhook_deliveries - visible in the Settings UI.
  • Idempotency: deliveries can repeat across retries; treat events as at-least-once.

Ready to subscribe?

Configure your first webhook in the dashboard. We'll fire a test delivery so you can verify the integration end-to-end before going live.

Talk to Sales