Event webhook

Get signed JSON on your own server when something happens on any of your websites, such as a draft waiting for review, an article going live or a ranking drop.

What it is

The event webhook sends a small, signed JSON message to a URL you choose whenever something happens on any website in your account: a draft is ready for review, an article is published, a report is ready, a key stops working, and more. Use it to feed your own dashboards, ticketing, CRM or chat tools.

It is separate from publishing by webhook. The publishing webhook delivers whole articles to your site. The event webhook delivers no article content: it tells your system that something happened, with ids, titles and a link into your Zivooo dashboard.

  • Who can set it: the account owner. Admins, editors and viewers see whether it is set, but can't change it.
  • What it covers: every website in the account. Each message says which website it is about.
  • Where: Settings → Branding, in the Notifications card, under Event webhook.

Requirements

  • A public https address, up to 500 characters. Plain http, localhost and private network addresses are refused, and the address is checked again before every delivery.
  • No redirects. Deliveries don't follow them, so give the final address.
  • Accept a POST with a JSON body and answer with any 2xx status within 10 seconds.

Set it up

  1. 1Go to Settings → Branding and turn on the Event webhook switch under Notifications.
  2. 2Paste your endpoint's https URL and click Connect.
  3. 3Your signing secret appears below. Copy it into your server's configuration (an environment variable, never in code).
  4. 4Click Send a test. Zivooo sends a signed test.ping right away and tells you whether your server answered with a 2xx.

The webhook gets every event listed below. Use the type field to act only on the ones you need.

The signing secret

The secret starts with whsec_ and signs every delivery, so your server can check each one came from Zivooo. It isn't kept on the page: only the owner can show it again.

  • Show signing secret shows it again, to the owner, whenever you need it.
  • To get a new secret, or to change the address, turn the switch off and on again and paste the address. The old secret stops working at once, so give your server the new one, shown when it is made.
Note

Use the whole secret, including the whsec_ prefix, as the HMAC key. It is a plain string: don't decode it.

Events

Every event has a type and its own data fields. Every data object (except test.ping's) also carries url, a link to the right page in your Zivooo dashboard. A field with no value is sent as null.

TypeWhen it is sentdata fields
article.ready_for_reviewA finished draft waits for someone to review it.articleId, title, keyword, source (plan or write), url
article.publishedAn article went live on the website.articleId, title, keyword, publishedUrl (the live page, or null), platform (the publishing destination, such as wordpress_plugin, ghost or custom), url
article.publish_failedAn article couldn't be published after every retry.articleId, title, keyword, reason, url
report.readyA weekly report or a monthly progress report is ready.kind (weekly or monthly), periodStart, periodEnd, url
key.failedA provider refused one of the account's keys, so work that needs it is paused.provider (Anthropic, DataForSEO or FAL.ai), reason, url
fair_use.near_capA website, or the account for an account-wide limit, has used 80% of a fair use limit. Sent once per limit per period.kind, label, used, cap, period, url
ranking.droppedA tracked keyword fell 5 or more places since its previous check, or fell out of the top 100.keyword, country, from, to (null when out of the top 100), url
visibility.droppedA website's AI visibility over the last 7 days fell 10 or more points against the 7 days before. Sent at most once a week per website.from, to, points, url
test.pingOnly when you click Send a test. Always sent, whatever events you ticked.message

A few field details:

  • articleId is the article's id in Zivooo, the same id the publishing webhook sends. The API lists published posts under their own id.
  • fair_use.near_cap: kind is one of articles, pageAudits, research, brandTruth, linkResearch, crawlPages or domainReports, and label is its readable name. period is the month (2026-10) for monthly limits, and the day (2026-10-22) for the daily search limit. Domain reports are counted across the account, so that event has site: null.
  • ranking.dropped: from and to are positions in search results, and country is the tracked keyword's two letter country code.
  • visibility.dropped: from and to are the share of AI answers that mentioned your brand, as whole percentages, and points is the fall.
  • key.failed is about the account, not one website, so it has site: null.
  • reason fields are plain sentences meant for people, not codes to match on.
  • Fair use and AI visibility events are found once a day, in the late afternoon UTC. The others are sent as they happen.

Request

Method: POST. Each delivery carries these headers:

Content-Type: application/json
User-Agent: Zivooo-Webhooks/1.0
Zivooo-Event: article.published
Zivooo-Delivery: evt_3f9c1a7e5b2d4c6f8a0b1c2d3e4f5a6b
Zivooo-Signature: t=1761379200,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
  • Zivooo-Event is the event's type.
  • Zivooo-Delivery is the event's id, the same as in the body.
  • Zivooo-Signature is t= the time it was sent in Unix seconds, then v1= the hex HMAC-SHA256 of the string t + "." + body, keyed with your signing secret.

Payload

Every event has the same envelope: id, type, createdAt (ISO 8601, UTC), site (the website's id and domain, or null for account-wide events) and data.

{
  "id": "evt_3f9c1a7e5b2d4c6f8a0b1c2d3e4f5a6b",
  "type": "article.published",
  "createdAt": "2026-10-25T09:00:00.000Z",
  "site": {
    "id": "651f3c2a9b1e4a0012ab34cd",
    "domain": "example.com"
  },
  "data": {
    "articleId": "6720b9e41c2a4f0012cd5678",
    "title": "How to Do X",
    "keyword": "how to do x",
    "publishedUrl": "https://example.com/blog/how-to-do-x",
    "platform": "wordpress_plugin",
    "url": "https://zivooo.com/dashboard/editor/6720b9e41c2a4f0012cd5678"
  }
}

An account-wide event:

{
  "id": "evt_8b7a6c5d4e3f2a1b0c9d8e7f6a5b4c3d",
  "type": "key.failed",
  "createdAt": "2026-10-25T11:42:10.000Z",
  "site": null,
  "data": {
    "provider": "DataForSEO",
    "reason": "DataForSEO rejected the login and password. They may have been changed.",
    "url": "https://zivooo.com/dashboard/settings/account"
  }
}

Messages carry ids, titles and links, never a key, a cost or a secret. New event types may be added later: ignore a type you don't know and answer it with a 2xx.

Verify the signature

  1. 1Read the raw request body exactly as received. Parsing the JSON and serializing it again changes the bytes and breaks the check.
  2. 2Split Zivooo-Signature on commas into t and v1.
  3. 3Compute the hex HMAC-SHA256 of t, a dot and the raw body, keyed with your signing secret.
  4. 4Compare it with v1 in constant time.
  5. 5Reject the delivery if t is more than 5 minutes from your server's clock. Each retry is signed again with a fresh time, so this never rejects a genuine retry.

Node.js (Express):

const crypto = require("crypto");
const express = require("express");

const app = express();
const SECRET = process.env.ZIVOOO_WEBHOOK_SECRET; // whsec_...
const TOLERANCE_SECONDS = 300;

function verify(rawBody, header) {
  const parts = Object.fromEntries(
    String(header || "").split(",").map((p) => p.trim().split("="))
  );
  const t = Number(parts.t);
  if (!t || !parts.v1) return false;
  if (Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) return false;
  const expected = crypto
    .createHmac("sha256", SECRET)
    .update(`${t}.${rawBody}`)
    .digest("hex");
  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(parts.v1, "hex");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// express.raw keeps the body as the exact bytes Zivooo sent.
app.post("/zivooo-events", express.raw({ type: "application/json" }), (req, res) => {
  const raw = req.body.toString("utf8");
  if (!verify(raw, req.get("Zivooo-Signature"))) return res.sendStatus(400);
  const event = JSON.parse(raw);
  // Answer first, then do the slow work in the background.
  res.sendStatus(200);
  handleEvent(event);
});

Python (Flask):

import hashlib
import hmac
import json
import os
import time

from flask import Flask, abort, request

app = Flask(__name__)
SECRET = os.environ["ZIVOOO_WEBHOOK_SECRET"].encode()  # whsec_...
TOLERANCE_SECONDS = 300


def verify(raw_body: bytes, header: str) -> bool:
    parts = dict(p.strip().split("=", 1) for p in (header or "").split(",") if "=" in p)
    try:
        t = int(parts["t"])
        v1 = parts["v1"]
    except (KeyError, ValueError):
        return False
    if abs(time.time() - t) > TOLERANCE_SECONDS:
        return False
    expected = hmac.new(SECRET, f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, v1)


@app.post("/zivooo-events")
def zivooo_events():
    raw = request.get_data()  # the raw bytes, not request.json
    if not verify(raw, request.headers.get("Zivooo-Signature", "")):
        abort(400)
    event = json.loads(raw)
    handle_event(event)
    return "", 200

PHP:

<?php
$secret = getenv('ZIVOOO_WEBHOOK_SECRET'); // whsec_...
$raw = file_get_contents('php://input');
$header = $_SERVER['HTTP_ZIVOOO_SIGNATURE'] ?? '';

$parts = [];
foreach (explode(',', $header) as $pair) {
    [$k, $v] = array_pad(explode('=', trim($pair), 2), 2, '');
    $parts[$k] = $v;
}
$t = (int) ($parts['t'] ?? 0);
$v1 = $parts['v1'] ?? '';

$expected = hash_hmac('sha256', $t . '.' . $raw, $secret);
if (!$t || abs(time() - $t) > 300 || !hash_equals($expected, $v1)) {
    http_response_code(400);
    exit;
}

$event = json_decode($raw, true);
http_response_code(200);
// handle $event here

Responses and retries

Answer with any 2xx within 10 seconds and the delivery counts as done. Zivooo doesn't read your response body. If your work takes longer, answer first and do it in the background.

Your serverWhat happens
Answers 2xxDelivered.
Answers 5xx, 408 or 429, takes longer than 10 seconds, or can't be reachedRetried after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours: up to 6 tries in all.
Answers any other 4xxNot retried. Retrying won't change the answer.
Answers with a redirect (3xx)Not retried. Save the final address instead.
The address now points at a private network, or its name doesn't resolveNot retried.

While deliveries fail, the Event webhook row shows that messages are failing, since when, and the last error. Once one goes through again, it shows when it was last sent.

Turned off after 3 days

If deliveries keep failing for 3 days with none getting through, Zivooo turns the event webhook off and emails the account owner once. The row then shows when it stopped and the last error. Nothing is sent while it is off, and events from that time are not sent later.

To turn it back on, fix your endpoint, turn the Event webhook switch on and Connect the URL again. Connecting the same URL keeps your signing secret; a different one gets a new secret.

Duplicates and order

Each occurrence is sent once, with one id that stays the same across its retries. A retry can still reach you twice if your server did the work but answered too late, so store the id (or the Zivooo-Delivery header) of each event you handle and skip one you have already seen.

Retries mean events can arrive out of order. Use createdAt to order them, and treat each event as a prompt to look, not as the complete state of the website.

FAQ

Can each website have its own event webhook?

No. There is one event webhook for the account, and it gets the events of every website. Use site.id or site.domain to route them.

Who can see or change it?

Only the account owner can connect it, show the secret, send a test or turn it off. Other team members see whether it is set.

How do I test my endpoint before real events happen?

Turn the webhook on, then click Send a test. It sends a signed test.ping straight away and tells you whether your server answered with a 2xx. You can send 10 tests every 10 minutes.

My signature check fails. What should I check?

Most often the body was parsed and serialized again before hashing: hash the raw bytes. Then check you used the whole secret including whsec_, that you made a new secret or changed the URL since you copied it, and that your server's clock is right.

Does the event webhook publish articles?

No. To receive articles on your own system, use publishing by webhook or the API. The two can run side by side.

How do I stop it?

Turn off the Event webhook switch on Settings → Branding. Nothing more is sent, and you can turn it on again at any time.