Webhook Events
Subscribe to opportunity lifecycle, verification, and outcome events as HMAC-signed POSTs.
Quick start
- Generate a webhook in Signal360 → Settings → Webhooks. Pick events, save.
- Copy the secret once at creation. Store it in your environment.
- Build a public POST endpoint that validates the signature (examples below).
- 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
| Header | Value |
|---|---|
X-Signal360-Signature | sha256=<hex> - HMAC-SHA256 of the raw body using your webhook secret |
X-Signal360-Event | e.g. opportunity.resolved |
X-Signal360-Delivery-Attempt | 1, 2, or 3 - increments on retries |
User-Agent | Signal360-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.openedWhen a new opportunity is detected and persisted via OpportunityService.open() - first time only per dedupeKey.
| Field | Type | Description |
|---|---|---|
opportunity | Opportunity | Full opportunity record |
opportunity.assignedWhen an opportunity transitions into `assigned` status - typically when a user assigns an owner.
| Field | Type | Description |
|---|---|---|
opportunity | Opportunity | Updated opportunity record |
fromStatus | string | Previous status |
toStatus | string | Always `assigned` |
opportunity.resolvedWhen verification (LLM-panel, re-crawl, or time-window) confirms the fix worked.
| Field | Type | Description |
|---|---|---|
opportunity | Opportunity | Resolved opportunity |
opportunity.regressedWhen verification fails - the fix did not resolve the issue.
| Field | Type | Description |
|---|---|---|
opportunity | Opportunity | Regressed opportunity |
verification.completedWhen any verification run reaches a terminal state (pass / fail / inconclusive).
| Field | Type | Description |
|---|---|---|
opportunityId | integer | Opportunity the verification ran for |
result | "pass" | "fail" | "inconclusive" | Verification outcome |
verification | OpportunityVerification | undefined | The verification record (if available) |
outcome.finalizedWhen a Phase B outcome row receives its post-window measurement (typically 30-60 days after resolution).
| Field | Type | Description |
|---|---|---|
opportunityId | integer | Source opportunity |
outcomeId | integer | Outcome record id |
metricKind | string | citation_rate | sov | sentiment | ai_referral_traffic | conversions | revenue |
delta | number | null | Absolute 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