Webhooks

A webhook alert POSTs its digest to a URL you control, signed so you can prove it came from BrandSonar.

Webhooks are one of the three destinations an alert can send to. Everything about configuring the alert, including its filters and its schedule, works the same as it does for email and Slack; this page covers what arrives at the other end.

Setting one up

Create an alert, choose Webhook, and give it an HTTPS URL. BrandSonar rejects plain HTTP, and rejects addresses that resolve to private infrastructure such as loopback, private ranges, link-local, and bare internal hostnames, so the endpoint has to be publicly reachable.

A signing secret beginning whsec_ is generated when the alert is created, and shown in the alert's settings. Store it wherever your receiver keeps its secrets. Regenerating it from the same screen invalidates the old one immediately.

The request

Method
POST, with a Content-Type: application/json body.
X-BrandSonar-Event
The event name. Currently always alert.digest.
X-BrandSonar-Timestamp
Unix seconds at which the request was signed.
X-BrandSonar-Signature
sha256=<hex>, the HMAC-SHA256 of `${timestamp}.${rawBody}` using the alert's secret.
User-Agent
BrandSonar-Webhook/1.0

Respond with any 2xx status within 10 seconds. Anything else is treated as a failed delivery and logged: a non-2xx, a timeout, or a redirect, which is not followed. Do the real work asynchronously and acknowledge fast.

The payload

{
  "event": "alert.digest",
  "deliveredAt": "2026-08-15T09:00:00.000Z",
  "alert": {
    "id": "019f21a0-4c11-7e3a-9d02-6b8e0f14c7d5",
    "name": "Negative mentions"
  },
  "brand": {
    "id": "019edc1a-6383-7c72-9f3c-e19dd5e91f6c",
    "name": "Acme"
  },
  "totalCount": 3,
  "mentions": [
    {
      "id": "019edc44-1f02-7a90-b3c8-27d51e6a0f4b",
      "platform": "reddit",
      "sentiment": "negative",
      "title": null,
      "content": "Acme's onboarding flow lost my import halfway through.",
      "authorName": null,
      "authorUsername": "jordanops",
      "sourceUrl": "https://reddit.com/r/saas/comments/...",
      "relevanceScore": 88,
      "publishedAt": "2026-08-15T08:41:00.000Z"
    }
  ],
  "dashboardUrl": "https://brandsonar.com/mentions"
}
event
Always alert.digest today. Check it rather than assuming, so a future event type does not break your handler.
deliveredAt
ISO 8601 timestamp of the delivery.
alert
The id and name of the alert that fired.
brand
The id and name of the brand it watches.
totalCount
How many mentions matched. This can exceed the length of mentions.
mentions
The matching mentions, capped at 50 per delivery. Fetch the rest through the REST API when totalCount is larger.
dashboardUrl
A link back into the app.

Verifying the signature

Recompute the HMAC over the raw request body and compare it against the header in constant time. Verify before parsing: JSON.parse followed by re-serialising produces different bytes and a signature that will never match.

import { createHmac, timingSafeEqual } from "node:crypto";

// rawBody must be the exact bytes received. Parse the JSON only after
// verifying, since re-serialising changes the signature.
export function verify(rawBody: string, headers: Headers, secret: string) {
  const timestamp = headers.get("X-BrandSonar-Timestamp");
  const received = headers.get("X-BrandSonar-Signature");
  if (!timestamp || !received) return false;

  // Reject replays of an old, valid delivery.
  const age = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (!Number.isFinite(age) || age > 300) return false;

  const hex = createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`)
    .digest("hex");
  const expected = Buffer.from(`sha256=${hex}`);
  const actual = Buffer.from(received);

  return expected.length === actual.length && timingSafeEqual(expected, actual);
}

Comparing the timestamp against the current time, as above, is what stops an attacker replaying a delivery they captured earlier. Five minutes is a reasonable window.

Rotating the secret

Open the alert in Alerts and regenerate the signing secret. The old secret stops working the moment you do, so update your receiver first if it validates strictly.

A failed delivery does not advance the alert's cursor, so the same mentions are attempted again on the next run. Key your handler on each mention's id so a redelivery is a no-op rather than a duplicate.