WavefrontDevelopers

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

EventWhen
generation.ready_for_reviewA draft or revision finished and is waiting for review
generation.review_requestedSomeone asked a teammate to review a draft
generation.needs_inputA draft stopped to ask a question
generation.failedA draft failed
version.approvedA version was approved: by a person, or by autopilot under gated auto-approve (`via`)
version.changes_requestedA person asked for changes to a version
publication.publishedAn approved version reached its destination
publication.failedPublishing failed
publication.withdrawnA publication was withdrawn, by an owner or because its page left the destination
schedule.cancel_requiredA 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.createdSomeone commented on a piece of content
plan.readyThe strategist finished a plan and says it is ready to save
plan.savedA strategy's plan was saved as workspace documents
autopilot.needs_reviewAutopilot 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_missedA calendar date passed with nothing published (autopilot on), with why each post didn't go out
suggestions.readyNew 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.testSent 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.

version.approved
{
  "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"
    }
  }
}
publication.published
{
  "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"
    }
  }
}
schedule.cancel_required
{
  "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 after whsec_. 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:

Node, with a library
// npm install standardwebhooks
import { Webhook } from "standardwebhooks";

const event = new Webhook(process.env.WAVEFRONT_WEBHOOK_SECRET).verify(rawBody, headers);

Or without a dependency:

Node
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);
  });
}
Express
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);
});
Python
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 False

Delivery 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-After header can lengthen a wait.
  • A 410 Gone turns 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 with PATCH …/webhooks/{id} and {"enabled": true}.
  • Delivery is at least once, and events can arrive out of order. Use webhook-id to deduplicate and timestamp to order.
  • Events that happen while a webhook is paused or off are not recorded for it.

Test, redeliver and rotate

  • POST …/webhooks/{id}/test sends a webhook.test event now.
  • GET …/webhooks/{id}/deliveries is the delivery log, kept 30 days, with each attempt's status code and error. POST …/deliveries/{did}/redeliver sends a finished delivery again with the same webhook-id and body. Tests and redeliveries share a limit of 10 a minute per workspace.
  • POST …/webhooks/{id}/rotate-secret returns a new secret once. For 24 hours both secrets sign each request, so you can switch without missing a delivery.