Webhooks. Real-time events.

Get an HTTPS callback the moment an affiliate link breaks, recovers, redirects, or a scan completes. HMAC-signed, retried with exponential backoff for 24 hours, with a delivery log you can replay from. Pipe into Slack, Discord, Zapier, or any HTTPS endpoint your team uses.

Pro feature. Up to 30 webhooks per workspace, scoped to the workspace or to individual sites.

Why this matters.

A scan tells you what's broken. A digest tells you once a week. A webhook tells you in the same minute it happens. For teams with editors, ops people, or revenue dashboards, that gap is where commission is lost. Webhooks close it.

Event types.

Subscribe to all of them, or filter to the ones your workflow needs. Each event fires exactly once per state change (no flooding the same broken link every scan).

TypeWhen it fires
link.brokenAn affiliate link is newly detected as broken or timing out. Diff-based, so it won't re-fire while the same link stays broken.
link.recoveredA previously-broken affiliate link is no longer broken (fixed, removed from the post, or replaced).
link.redirectAn affiliate link is newly detected as redirecting. Often the first signal of a merchant URL change.
link.tag_strippedYour affiliate tag was dropped somewhere in the redirect chain. You're losing commission on this link even though it works for the visitor.
scan.completedA site scan finished. Includes full totals (broken, redirect, blocked, ok) plus delta vs the previous scan.
site.connectedA new site was added to your workspace.
tracking.sync_failedA revenue-provider sync (e.g. WeCanTrack) errored out 3 times in a row. Caught by the connector health monitor.
revenue.alertRevenue dropped past the alert threshold for a connected site.
webhook.testFired by the "Send test event" button in the dashboard. Always delivered, ignores event filters.

Payload.

Every event ships in a versioned envelope. The event_id is stable across delivery retries, so use it to dedupe on your side. delivery_id is unique per attempt.

{ "api_version": "2026-05-01", "event_id": "9f4d2e1c-8b4a-4f2a-a1c0-b2e5d7c8f9a0", "delivery_id": "1c5b9f3e-6a8d-4c2b-9e7f-a3b1d4c5e6f7", "type": "link.broken", "created_at": "2026-05-01T07:32:14.092Z", "workspace_id": 5, "site_id": 12, "data": { "url": "https://example.com/affiliate/123?ref=abc", "status_code": 404, "status_class": "broken", "network": "amazon", "days_failing": 3, "is_affiliate": true, "found_in_post_id": 4421, "found_in_post_title": "Best wireless headphones 2026", "found_in_post_url": "https://yoursite.com/best-headphones/", "final_url": "https://example.com/404" }, "_docs_url": "https://linkpulse.dev/connectors/webhooks/events#link-broken" }

Signature verification.

Every request is signed with HMAC-SHA256 over ${timestamp}.${rawBody} using your webhook's secret. The header is X-LinkPulse-Signature: t=<ts>,v1=<hex>. Reject anything older than 5 minutes to defeat replay attacks.

Node.js (Express)

const crypto = require('crypto'); app.post('/webhooks/linkpulse', express.raw({ type: 'application/json' }), // raw body required (req, res) => { const sig = req.header('X-LinkPulse-Signature') || ''; const parts = Object.fromEntries(sig.split(',').map(s => s.split('='))); const t = parseInt(parts.t, 10); if (!t || Math.abs(Date.now()/1000 - t) > 300) return res.status(400).end(); const expected = crypto .createHmac('sha256', process.env.LP_WEBHOOK_SECRET) .update(`${t}.${req.body.toString()}`) .digest('hex'); if (parts.v1 !== expected) return res.status(401).end(); const event = JSON.parse(req.body.toString()); handle(event); // dedupe by event.event_id res.json({ ok: true }); });

PHP

$raw = file_get_contents('php://input'); $sig = $_SERVER['HTTP_X_LINKPULSE_SIGNATURE'] ?? ''; parse_str(str_replace(',', '&', $sig), $parts); $t = (int)($parts['t'] ?? 0); if (!$t || abs(time() - $t) > 300) { http_response_code(400); exit; } $expected = hash_hmac('sha256', $t . '.' . $raw, getenv('LP_WEBHOOK_SECRET')); if (!hash_equals($expected, $parts['v1'] ?? '')) { http_response_code(401); exit; } $event = json_decode($raw, true); handle($event); // dedupe by $event['event_id'] http_response_code(200);

Recipes.

Pre-built receivers you can drop in without writing code.

Slack

Post broken-link alerts to a channel.

  1. Slack admin → Apps → search "Incoming Webhooks" → install
  2. Choose channel, copy the webhook URL
  3. Paste into /app/webhooks with events link.broken + link.tag_stripped

Discord

Same idea, dev-team channel.

  1. Channel settings → Integrations → New Webhook
  2. Copy the URL (ends with /api/webhooks/...)
  3. Paste into the LinkPulse webhook UI, append /slack for Slack-compatible formatting

Zapier

Fan out to anything (email, sheets, CRM).

  1. Create a Zap with trigger "Webhooks by Zapier → Catch Hook"
  2. Copy the Zapier Catch URL
  3. Paste into /app/webhooks, then chain Zapier actions

Custom HTTPS

Your own backend, your rules.

  1. Expose an HTTPS endpoint that accepts POST application/json
  2. Verify the signature (snippet above), dedupe by event_id
  3. Add the URL in the dashboard, click Test

Reliability.

Webhooks aren't fire-and-forget. We treat them like a queue, not a notification.

Retry with backoff

Failed deliveries retry at 5s, 30s, 2m, 10m, 1h, 6h, 24h. After 7 failed attempts the delivery moves to dead-letter, still replayable from the dashboard.

Per-webhook rate limit

At most one delivery per webhook per 2 seconds. A site with 1,000 broken links won't drown your Slack channel; events spread out automatically.

SSRF protection

Webhook destinations are DNS-resolved before each POST. Private IPs (10/8, 192.168/16, link-local, IPv6 ULA) are rejected at delivery time.

Delivery log

The last 30 days of attempts are visible per webhook: status, response code, response body, latency. Manual replay button on failed and dead deliveries.

Set up your first webhook.

It takes a minute. Pick a name, paste a URL, choose your events, save. Click Test to confirm your endpoint receives the event. Real events start flowing on the next scan.

Open the dashboard