Blog

Stop polling our API for score changes — use the webhook instead

If you're checking /api/v1/visibility on a timer, you're solving a push problem with a pull tool. Here's exactly how PingMyBrand's outbound webhook works — the real payload, the real retry schedule, and a worked example that forwards a score drop straight into Slack.

The PingMyBrand team4 min read
On this page
  1. 01Why this is a push problem, not a pull one
  2. 02What actually fires, and when
  3. 03Verify it, or don't trust it
  4. 04A worked example: forward a real drop into Slack
  5. 05What "retried" actually means for your receiver
  6. 06Get the key, wire the URL, done

If your integration polls the read-only visibility API on a cron job to catch a score change, you're solving a push problem with a pull tool — burning rate-limit budget to ask "did anything change yet?" dozens of times for every one time the answer is actually yes. The fix is the webhook, not a tighter polling interval: register one URL, and we POST to it — HMAC-signed, retried, documented — the moment a brand your account tracks has a real, nonzero score change. This post is the mechanical detail the API reference states but doesn't dwell on: why push beats poll here specifically, and a worked example wiring a scan result straight into Slack.

Why this is a push problem, not a pull one

A visibility score doesn't change on your schedule — it changes when we finish a weekly re-scan, or when you trigger a manual one, which happens on our clock, not a fixed interval you can predict. Polling for that means picking an interval that's either too slow (you find out hours after a real drop, while a customer or your own team already noticed on Twitter) or wasteful (checking every few minutes for an event that happens, at most, once a week per brand). A webhook sidesteps the guessing entirely: nothing arrives until something genuinely changed, and it arrives within seconds of the change instead of the top of your next polling window.

What actually fires, and when

Register a callback URL with a POST to /api/v1/webhooks (bearer-key auth, same key as the REST API) and you get exactly one live registration per account — registering again rotates it, replacing the old URL and secret outright, so decommission any consumer of the old URL before you rotate, not after. Delivery fires only on scan_completed with a real, nonzero score delta — an unchanged score after a re-scan never fires anything, and there's no way to trigger a test delivery from a scan that didn't actually move. That last part is deliberate: a webhook that fires on request would tell you nothing about whether it's wired correctly for the case that actually matters.

The payload is small and flat on purpose: domain, slug, the new score, the previousScore, the computed delta, a per-engine breakdown, and a timestamp. Enough to post a useful Slack message or update a dashboard cell without a follow-up API call — though the domain and slug are there specifically so you can call /api/v1/visibility for the full per-engine detail if your use case needs more than the delta.

Verify it, or don't trust it

Every delivery carries an X-PingMyBrand-Signature: sha256=... header — an HMAC-SHA256 of the raw request body, keyed with the secret you were shown exactly once at registration. Recompute it yourself before you act on a payload:

import crypto from "crypto";

function verify(secret, rawBody, header) {
  const [, sig] = header.split("=");
  const expected = crypto
    .createHmac("sha256", secret)
    .update(rawBody)
    .digest("hex");
  return crypto.timingSafeEqual(Buffer.from(sig, "hex"), Buffer.from(expected, "hex"));
}

Skip this step and your endpoint is just an unauthenticated public URL that happens to receive JSON — anyone who finds it can POST a fabricated "score dropped to 0" payload and watch your team panic over nothing.

A worked example: forward a real drop into Slack

Here's the shape of a minimal receiver — verify, check the sign of the delta, forward only the case a human actually needs to see:

app.post("/pingmybrand-hook", express.raw({ type: "*/*" }), async (req, res) => {
  const sig = req.headers["x-pingmybrand-signature"];
  if (!verify(process.env.PMB_WEBHOOK_SECRET, req.body, sig)) {
    return res.status(401).end();
  }
  const event = JSON.parse(req.body);
  res.status(200).end(); // ack immediately — do the Slack call after

  if (event.delta < 0) {
    await postToSlack(
      `⚠️ AI visibility for ${event.domain} dropped ${Math.abs(event.delta)} points (now ${event.score}/100)`
    );
  }
});

Two things in that snippet are load-bearing, not stylistic. First, acknowledge the request before doing any slow work — respond 200 immediately, then call Slack; a receiver that makes us wait on a third-party API risks hitting our 10-second delivery timeout and triggering a retry for a delivery you actually already received. Second, filter on the delta's sign yourself — every real change delivers, rises included, so if you only care about regressions, that's a one-line if, not a setting we expose.

What "retried" actually means for your receiver

A failed delivery — a non-2xx response, a timeout past 10 seconds, or a thrown network error — retries up to 4 attempts total, waiting 0.5s, then 1s, then 2s between them (the same schedule the API reference documents). A run counts as successful the instant any attempt gets a 2xx back; the moment your receiver acknowledges, retries stop. Design your endpoint to be safely re-postable: if your receiver crashes mid-processing after accepting a request but before you record it internally, a retry for the same event will simply arrive again with the same payload. Deduping on domain + timestamp before you act on a delivery is a five-minute safeguard against double-posting to Slack if that ever happens.

Get the key, wire the URL, done

Webhooks are live on every paid plan — no separate add-on, same bearer key you'd mint for the REST API. Mint one from your account page, register your URL with the curl example on the developer reference, and the next real score change — yours or a client's, if you're tracking multiple brands — lands in your own system within seconds, signed, instead of waiting for your next poll to notice.

Does AI recommend you, or a competitor?

Enter your domain. We ask 25 real buyer questions across ChatGPT, Claude, Gemini & Perplexity and show you, per question, whether you're named — the exact sentence, not a green dot. Free, no signup, about a minute.

Free · no signup · 4 engines · ~60 seconds