Skip to content

Webhooks

SEOJuice can push events to your own endpoint as they happen — a change gets approved, a report finishes, a GBP review comes in — instead of you polling the API. Each webhook endpoint is configured with a URL, a list of event types to subscribe to, and a per-endpoint secret used to sign every delivery.

Event catalog

These are the event types the platform actually emits today. Subscribe an endpoint to any subset.

EventFired when
change.approvedA pending change is approved (manually or via auto-approve)
change.appliedA change is written live to the page
change.rejectedA pending change is rejected
change.revertedA live or approved change is rolled back
change.pulledA change is pulled by a CMS/platform integration for delivery
change.verifiedA pulled change is confirmed live by the integration
report.completedA website report finishes generating
report.failedA website report generation fails
content_gap.foundA new content opportunity is detected
wordpress.publish_successA generated content piece publishes to WordPress
wordpress.publish_failedA WordPress publish attempt fails
gbp.review_receivedA new Google Business Profile review is fetched
gbp.auto_reply_postedAn auto-reply is posted to a GBP review

All change.* events share one payload shape, described below. The rest carry event-specific fields — report_id/json_url/pdf_url for reports, cluster_id/page_count_change for clusters, old_score/new_score/change for AISO, and so on. Every payload includes event and a website: { domain } object.

The change.* payload

Every change.* event’s change key is the same object returned by the Partners API for a change record — built by serialize_change_record. It includes the standard fields (id, change_type, status, risk_level, page_url, proposed_value, previous_value, timestamps, etc.) plus:

  • mutation_type — the underlying mutation kind (content_patch, internal_link_insert, meta_update, schema_inject, alt_text_set, broken_link_replace, …).
  • verification_state / outcome_state — lifecycle tracking for pulled/verified integrations.
  • apply — present (non-null) only when the change’s lifecycle state is approved or live. It carries the exact instructions needed to apply the change to your own copy of the page — the same keys the injection payload builder uses (e.g. selector, operation, replacement_html for a content_patch; keyword/url for an internal link; field/value for a meta or schema update). apply is null for changes that are still pending, rejected, or reverted.

This means a change.approved or change.applied webhook delivery is self-contained: you don’t need a follow-up API call to know what to apply — the apply object is already in the payload.

Verifying a delivery

Every delivery is a POST with a JSON body and three headers:

HeaderValue
X-SEOJuice-SignatureHex-encoded HMAC-SHA256 of the raw request body, keyed with your endpoint’s secret
X-SEOJuice-EventThe event type string, e.g. change.applied
X-SEOJuice-Delivery-IDA unique ID for this delivery attempt (stable across retries of the same delivery)

The signature is computed as:

signature = hex(HMAC-SHA256(key = secret, message = raw_body_bytes))

Two details that are easy to get wrong and will silently break verification:

Python example

from seojuice import verify_webhook_signature
# In your endpoint handler:
raw_body = request.body # bytes, read before any JSON parsing
signature = request.headers["X-SEOJuice-Signature"]
if not verify_webhook_signature(webhook_secret, raw_body, signature):
return HttpResponse(status=401)
payload = json.loads(raw_body)

Node example

import { verifyWebhookSignature } from "seojuice";
// Express: use express.raw({ type: "application/json" }) on this route
// so req.body is the raw Buffer, not a pre-parsed object.
app.post("/webhooks/seojuice", express.raw({ type: "application/json" }), (req, res) => {
const signature = req.headers["x-seojuice-signature"];
if (!verifyWebhookSignature(webhookSecret, req.body, signature)) {
return res.sendStatus(401);
}
const payload = JSON.parse(req.body);
res.sendStatus(200);
});

PHP example

$rawBody = file_get_contents('php://input'); // raw bytes, before any parsing
$signature = $_SERVER['HTTP_X_SEOJUICE_SIGNATURE'] ?? '';
if (!\SEOJuice\Webhooks::verifySignature($webhookSecret, $rawBody, $signature)) {
http_response_code(401);
exit;
}
$payload = json_decode($rawBody, true);

Each SDK’s helper compares in constant time internally, so you never compare the hex strings with ==/=== yourself. If you verify by hand instead, use a constant-time comparison (hmac.compare_digest, crypto.timingSafeEqual, hash_equals) and guard against length mismatches.

Retries

A failed delivery (non-2xx response, timeout, or connection error) is retried up to 3 times with increasing backoff (1 minute, then 5 minutes, then 30 minutes). X-SEOJuice-Delivery-ID stays the same across retries of the same delivery, so use it to deduplicate on your end if you process a delivery more than once before returning a 2xx.