REST API
Event webhooks
Wavefront can POST a signed request to your HTTPS endpoint when something happens in a workspace: a draft is ready, a version is approved, a post goes live. Use webhooks instead of polling, and to trigger Zapier, Make or n8n.
Subscribe
Owners add webhooks in the web app under Settings → Integrations. Over the API, use a key with the webhooks and generations:read scopes (a key changes and removes only the webhooks it created):
curl -s -X POST "https://wavefront.adwave.com/v1/workspaces/adwave/webhooks" \
-H "Authorization: Bearer $WAVEFRONT_API_KEY" \
-H "content-type: application/json" \
-d '{"url":"https://example.com/wavefront","events":["version.approved","publication.published"],"description":"CMS sync"}'The 201 response holds the subscription and its signing secret (whsec_…), shown only this once. The URL must be public HTTPS; IP addresses and local names are refused. A workspace can have up to 20 subscriptions. GET …/webhooks/sample?event= returns sample bodies (the workspace's latest real events of that type, or a made-up example) for mapping fields before a real event arrives.
Events
| Event | When |
|---|---|
generation.ready_for_review | A draft or revision finished and is waiting for review |
generation.review_requested | Someone asked a teammate to review a draft |
generation.needs_input | A draft stopped to ask a question |
generation.failed | A draft failed |
version.approved | A version was approved: by a person, or by autopilot under gated auto-approve (`via`) |
version.changes_requested | A person asked for changes to a version |
publication.published | An approved version reached its destination |
publication.failed | Publishing failed |
publication.withdrawn | A publication was withdrawn, by an owner or because its page left the destination |
schedule.cancel_required | A scheduled post shouldn't go out (its approval was withdrawn, or a newer version replaced it), and Wavefront couldn't take it back at the destination |
comment.created | Someone commented on a piece of content |
plan.ready | The strategist finished a plan and says it is ready to save |
plan.saved | A strategy's plan was saved as workspace documents |
autopilot.needs_review | Autopilot couldn't approve a calendar draft (blockers left after any automatic revision, or the approving owner is gone); it waits for a person |
autopilot.slot_missed | A calendar date passed with nothing published (autopilot on), with why each post didn't go out |
suggestions.ready | New strategy suggestions from the site's search data wait for an owner or editor to approve (the weekly digest, or at once for a large drop in clicks) |
webhook.test | Sent only by Send test event (POST /workspaces/{ws}/webhooks/{id}/test); nobody subscribes to it |
Payloads
Every request body is {"type", "timestamp", "data"}. Payloads are thin: ids, titles and links, never article bodies. Fetch the rest from the API with a key. data.workspace names the workspace, data.actor_id who did it, and data.links where to read more. The exact schema of each event is in the webhooks section of the OpenAPI document.
{
"type": "version.approved",
"timestamp": "2026-10-12T14:00:00.000Z",
"data": {
"workspace": {
"id": "ws_example",
"slug": "acme",
"name": "Acme"
},
"actor_id": "usr_example",
"generation": {
"id": "gen_example",
"kind": "article",
"title": "Google Ads cost benchmarks by industry",
"content_id": "cnt_example"
},
"version_id": "ver_example",
"decided_by": "usr_example",
"via": "person",
"links": {
"app": "https://wavefront.adwave.com/w/acme/g/gen_example",
"api": "https://wavefront.adwave.com/v1/workspaces/acme/generations/gen_example",
"version": "https://wavefront.adwave.com/v1/workspaces/acme/versions/ver_example"
}
}
}{
"type": "publication.published",
"timestamp": "2026-10-12T14:00:00.000Z",
"data": {
"workspace": {
"id": "ws_example",
"slug": "acme",
"name": "Acme"
},
"actor_id": "usr_example",
"publication": {
"id": "pub_example",
"slug": "google-ads-cost-benchmarks",
"destination_id": "dst_example",
"url": "https://example.com/google-ads-cost-benchmarks"
},
"version_id": "ver_example",
"generation": {
"id": "gen_example",
"kind": "article",
"title": "Google Ads cost benchmarks by industry",
"content_id": "cnt_example"
},
"links": {
"app": "https://wavefront.adwave.com/w/acme/g/gen_example",
"api": "https://wavefront.adwave.com/v1/workspaces/acme/publications/pub_example"
}
}
}{
"type": "schedule.cancel_required",
"timestamp": "2026-10-12T14:00:00.000Z",
"data": {
"workspace": {
"id": "ws_example",
"slug": "acme",
"name": "Acme"
},
"actor_id": "usr_example",
"subject_id": "sch_example"
}
}Verify the signature
Requests follow Standard Webhooks. Each has three headers:
webhook-id: the event id (evt_…), the same on every retry and redelivery. Use it to skip duplicates.webhook-timestamp: Unix seconds when this attempt was signed. Reject ones more than 5 minutes from now.webhook-signature:v1,followed by the base64 HMAC-SHA256 of{webhook-id}.{webhook-timestamp}.{raw body}, keyed with the base64-decoded part of your secret afterwhsec_. While a rotated secret overlaps, the header holds two signatures separated by a space; accept either.
Always verify against the raw request body, before parsing it. The Standard Webhooks libraries do all of this:
// npm install standardwebhooks
import { Webhook } from "standardwebhooks";
const event = new Webhook(process.env.WAVEFRONT_WEBHOOK_SECRET).verify(rawBody, headers);Or without a dependency:
import crypto from "node:crypto";
/** True when a delivery is from Wavefront: pass the raw body, the headers and the whsec_ secret. */
export function verifyWavefront(rawBody, headers, secret, toleranceSeconds = 300) {
const id = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
const signatures = String(headers["webhook-signature"] ?? "").split(" ");
if (!id || !/^\d+$/.test(String(timestamp ?? ""))) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) return false;
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected = crypto
.createHmac("sha256", key)
.update(`${id}.${timestamp}.${rawBody}`)
.digest();
return signatures.some((part) => {
const [version, signature] = part.split(",");
if (version !== "v1" || !signature) return false;
const given = Buffer.from(signature, "base64");
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
});
}import express from "express";
import { verifyWavefront } from "./verify-wavefront.js";
const app = express();
// Verify against the exact bytes Wavefront sent, before any JSON parsing.
app.post("/wavefront", express.raw({ type: "application/json" }), (req, res) => {
const raw = req.body.toString("utf8");
if (!verifyWavefront(raw, req.headers, process.env.WAVEFRONT_WEBHOOK_SECRET)) {
return res.status(401).end();
}
const event = JSON.parse(raw);
// Deliveries can repeat: skip a webhook-id you have already handled.
res.status(204).end();
handle(req.headers["webhook-id"], event);
});import base64
import hashlib
import hmac
import time
def verify_wavefront(raw_body: bytes, headers, secret: str, tolerance_seconds: int = 300) -> bool:
"""True when a delivery is from Wavefront: pass the raw body, the headers and the whsec_ secret."""
msg_id = headers.get("webhook-id", "")
timestamp = headers.get("webhook-timestamp", "")
if not msg_id or not timestamp.isdigit():
return False
if abs(time.time() - int(timestamp)) > tolerance_seconds:
return False
key = base64.b64decode(secret.removeprefix("whsec_"))
signed = f"{msg_id}.{timestamp}.".encode() + raw_body
expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
for part in headers.get("webhook-signature", "").split(" "):
version, _, signature = part.partition(",")
if version == "v1" and hmac.compare_digest(signature, expected):
return True
return FalseDelivery and retries
- Answer with any 2xx within 15 seconds. Wavefront reads at most 2 KB of the response and doesn't follow redirects. Do slow work after you answer.
- Anything else is retried after about 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 8 hours and 9 hours: 8 attempts over about 24 hours. A
Retry-Afterheader can lengthen a wait. - A
410 Goneturns the webhook off at once. A webhook whose deliveries keep failing for 24 hours is turned off, and the owner who added it gets an email. Turn it back on withPATCH …/webhooks/{id}and{"enabled": true}. - Delivery is at least once, and events can arrive out of order. Use
webhook-idto deduplicate andtimestampto order. - Events that happen while a webhook is paused or off are not recorded for it.
Test, redeliver and rotate
POST …/webhooks/{id}/testsends awebhook.testevent now.GET …/webhooks/{id}/deliveriesis the delivery log, kept 30 days, with each attempt's status code and error.POST …/deliveries/{did}/redeliversends a finished delivery again with the samewebhook-idand body. Tests and redeliveries share a limit of 10 a minute per workspace.POST …/webhooks/{id}/rotate-secretreturns a new secret once. For 24 hours both secrets sign each request, so you can switch without missing a delivery.