Docs / Automate around it

Webhooks

Webhooks let Wavefront notify your systems the moment something happens to your content. When an article publishes, updates, gets taken offline, or a draft lands in review, we send a signed JSON POST to any HTTPS endpoint you choose.

They work out of the box with Zapier, Make, and n8n (paste the URL from their "catch webhook" trigger), and with any custom receiver you build yourself.

Common uses:

  • Post to Slack or Discord when an article goes live
  • Trigger a site rebuild (Netlify, Vercel, Cloudflare Pages) after a publish
  • Sync published content into your own database or search index
  • Ping your review team when a draft is ready for approval

Setting up a webhook

  1. Go to Settings → Webhooks and click Add.
  2. Give the endpoint a name, paste your HTTPS URL, and (recommended) set a signing secret.
  3. Pick the events you care about. Leave all boxes unchecked to receive every event.
  4. Click Save webhook.

The Add webhook endpoint dialog with a name, endpoint URL, signing secret, and event checkboxes

Your endpoint appears in the Webhooks card with its status, signing badge, and subscribed events:

The Webhooks card in Settings showing an active, signed endpoint subscribed to content.published and content.updated

Requirements:

  • The URL must be HTTPS and publicly reachable. Private, loopback, and internal addresses (localhost, 10.x, 192.168.x, cloud metadata IPs, and similar) are rejected.
  • Endpoint names are unique per workspace. Saving again with the same name updates the existing endpoint.
  • The number of webhook endpoints you can create depends on your plan.

Events

EventFires when
content.publishedAn article goes live for the first time
content.updatedA live article is re-published with changes
content.unpublishedAn article is taken offline
draft.ready_for_reviewA draft is moved into review
network.placement.proposedA Link Network partner article proposes a placement pointing at your site
network.placement.liveA placement pointing at your site is published on the partner page
network.placement.verifiedA placement passed verification (credits settle; hosts get this too when they earn)
network.placement.brokenA placement failed verification repeatedly (credits released or clawed back)
network.placement.removedA placement's host page was unpublished or the placement was closed out
visibility.audit.completedA Brand Visibility Audit finished (payload: overall + per-layer scores, new/resolved finding counts)
visibility.regressionA business-critical prompt stayed non-endorsed for two consecutive scans after an endorsed one (payload: prompt, engine, dates, hedge excerpts)

The network.placement. payloads are small JSON objects (placement id, type, target URL, host page URL where known, credits) rather than the full document payload below; partners are identified by domain only. The visibility. payloads are likewise compact JSON summaries.


The payload

Every delivery is a JSON POST. Payloads are versioned via the top-level version field so we can evolve the schema without breaking your receiver.

{
  "version": "2026-07-01",
  "event": "content.published",
  "orgId": "your-workspace-id",
  "target": "production",
  "provider": "wordpress",
  "externalId": "42",
  "externalUrl": "https://blog.example.com/your-slug",
  "strategy": { "id": "str_x", "slug": "editorial", "name": "Editorial" },
  "document": {
    "slug": "your-slug",
    "title": "SEO title",
    "h1": "Page heading",
    "seoDescription": "Meta description",
    "shortDescription": "Card preview text",
    "resourceType": "Guides",
    "authorName": "Jane Doe",
    "date": "2026-07-01",
    "bodyMarkdown": "## Clean canonical markdown…",
    "bodyHtml": "<h2>Rendered HTML…</h2>",
    "hero": { "filename": "hero.png", "url": "https://…/pub/a/…/hero.png", "alt": "…" },
    "assets": [{ "filename": "body1.png", "url": "https://…", "alt": "…" }]
  }
}

Field notes:

  • target is production or staging, depending on where the content was published.
  • provider, externalId, and externalUrl describe where the content lives in your connected CMS. They can be null for content that hasn't been pushed to a CMS.
  • document.bodyMarkdown is the clean, canonical markdown; document.bodyHtml is the same content rendered to HTML. Use whichever suits your pipeline.
  • Image URLs (hero.url, assets[].url) are public, content-addressed, and immutable, so they're safe to hotlink or fetch once and cache forever.

Request headers

HeaderValue
X-Waveform-EventThe event name (for example content.published)
X-Waveform-DeliveryUnique delivery id. Use it to deduplicate retries
X-Waveform-TimestampUnix time in milliseconds when the request was signed¹
X-Waveform-Signature-V2sha256= + HMAC-SHA256 of timestamp + "." + body¹
X-Waveform-SignatureLegacy body-only HMAC (no timestamp)¹

¹ Only present when the subscription has a signing secret.


Verifying signatures

If you set a signing secret, verify every request before trusting it. Compute the HMAC yourself and compare it to the header using a constant-time comparison. Reject stale timestamps to block replay attacks.

Node.js

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

export function verifyWebhook(req, secret) {
  const timestamp = req.headers["x-waveform-timestamp"];
  const signature = req.headers["x-waveform-signature-v2"]; // "sha256=<hex>"
  if (!timestamp || !signature) return false;
  // Reject stale timestamps (replay protection)
  if (Math.abs(Date.now() - Number(timestamp)) > 5 * 60 * 1000) return false;
  const expected = "sha256=" + createHmac("sha256", secret)
    .update(timestamp + "." + req.rawBody)
    .digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(signature);
  return a.length === b.length && timingSafeEqual(a, b);
}

PHP

function verify_webhook(string $rawBody, array $headers, string $secret): bool {
  $timestamp = $headers['X-Waveform-Timestamp'] ?? '';
  $signature = $headers['X-Waveform-Signature-V2'] ?? '';
  if (!$timestamp || !$signature) return false;
  if (abs((int)(microtime(true) * 1000) - (int)$timestamp) > 300000) return false;
  $expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
  return hash_equals($expected, $signature);
}

Python

import hmac, hashlib, time

def verify_webhook(raw_body: bytes, headers: dict, secret: str) -> bool:
    timestamp = headers.get("X-Waveform-Timestamp", "")
    signature = headers.get("X-Waveform-Signature-V2", "")
    if not timestamp or not signature:
        return False
    if abs(time.time() * 1000 - int(timestamp)) > 300_000:
        return False
    message = f"{timestamp}.".encode() + raw_body
    expected = "sha256=" + hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)

Important: compute the HMAC over the raw request body exactly as received. Parsing the JSON and re-serializing it will change the bytes and the signature won't match.


Retries and redelivery

Your endpoint should respond with a 2xx status within 15 seconds. Anything else (a 4xx/5xx response, a timeout, or a connection failure) counts as a failed attempt and is retried automatically with exponential backoff:

AttemptWait before retry
1 → 21 minute
2 → 35 minutes
3 → 430 minutes
4 → 52 hours
5 → 612 hours

After 6 total attempts the delivery is marked failed and we stop retrying.

Deliveries are at-least-once: in rare cases your endpoint may receive the same delivery twice. Use the X-Waveform-Delivery header to deduplicate.

Best practice: respond 200 immediately and process the payload asynchronously. Slow handlers get cut off at the 15-second timeout and end up in the retry queue.

The delivery log

Every attempt is recorded. Click Deliveries on any endpoint to see the status, HTTP response code, error message, and attempt count for each event, and to re-send any delivery manually with Redeliver:

The delivery log dialog showing a pending delivery with an HTTP 503 error and a successfully delivered event

Redeliver resets the attempt counter and sends the payload again right away. It works on delivered, pending, and failed rows.

Delivery history is retained for 30 days.


Managing webhooks from your own agent

Everything above is also available through the MCP tool surface, so a connected agent (Cursor, Claude, or any MCP client) can manage webhooks for you:

ToolWhat it does
list_webhook_subscriptionsList endpoints and the available event names
set_webhook_subscriptionCreate or update an endpoint (upsert by name or id)
delete_webhook_subscriptionRemove an endpoint and its delivery history
list_webhook_deliveriesInspect recent delivery attempts
redeliver_webhookRe-send a specific delivery

Creating and deleting endpoints requires the admin role. Secrets are stored encrypted and are never returned by any tool or API.


Common questions answered

Do I need a signing secret? No, but we strongly recommend one. Without it, anyone who discovers your endpoint URL could send it fake payloads. With a secret, you can verify every request came from us.

Can I point one endpoint at multiple events? Yes. Check as many events as you like, or leave them all unchecked to receive everything. You can also create separate endpoints per event if you prefer to route them differently.

What happens if my endpoint is down for a while? We retry with backoff for roughly 15 hours across 6 attempts. If your outage lasts longer, open the delivery log and click Redeliver on anything marked failed once you're back up.

Why did my delivery fail with "subscription removed or inactive"? The endpoint was deleted or switched off between the event firing and the delivery attempt. Re-activate the endpoint and use Redeliver if you still need the payload.

Can I receive webhooks on localhost during development? Not directly, since private and loopback addresses are blocked. Use a tunnel such as cloudflared tunnel or ngrok, which gives you a public HTTPS URL that forwards to your local server.

Is the payload the same for staging and production publishes? Yes, the schema is identical. Check the target field to tell them apart.