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
- Go to Settings → Webhooks and click Add.
- Give the endpoint a name, paste your HTTPS URL, and (recommended) set a signing secret.
- Pick the events you care about. Leave all boxes unchecked to receive every event.
- Click Save webhook.

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

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
| Event | Fires when |
|---|---|
content.published | An article goes live for the first time |
content.updated | A live article is re-published with changes |
content.unpublished | An article is taken offline |
draft.ready_for_review | A draft is moved into review |
network.placement.proposed | A Link Network partner article proposes a placement pointing at your site |
network.placement.live | A placement pointing at your site is published on the partner page |
network.placement.verified | A placement passed verification (credits settle; hosts get this too when they earn) |
network.placement.broken | A placement failed verification repeatedly (credits released or clawed back) |
network.placement.removed | A placement's host page was unpublished or the placement was closed out |
visibility.audit.completed | A Brand Visibility Audit finished (payload: overall + per-layer scores, new/resolved finding counts) |
visibility.regression | A 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:
targetisproductionorstaging, depending on where the content was published.provider,externalId, andexternalUrldescribe where the content lives in your connected CMS. They can benullfor content that hasn't been pushed to a CMS.document.bodyMarkdownis the clean, canonical markdown;document.bodyHtmlis 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
| Header | Value |
|---|---|
X-Waveform-Event | The event name (for example content.published) |
X-Waveform-Delivery | Unique delivery id. Use it to deduplicate retries |
X-Waveform-Timestamp | Unix time in milliseconds when the request was signed¹ |
X-Waveform-Signature-V2 | sha256= + HMAC-SHA256 of timestamp + "." + body¹ |
X-Waveform-Signature | Legacy 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:
| Attempt | Wait before retry |
|---|---|
| 1 → 2 | 1 minute |
| 2 → 3 | 5 minutes |
| 3 → 4 | 30 minutes |
| 4 → 5 | 2 hours |
| 5 → 6 | 12 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:

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:
| Tool | What it does |
|---|---|
list_webhook_subscriptions | List endpoints and the available event names |
set_webhook_subscription | Create or update an endpoint (upsert by name or id) |
delete_webhook_subscription | Remove an endpoint and its delivery history |
list_webhook_deliveries | Inspect recent delivery attempts |
redeliver_webhook | Re-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.