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.
| Event | Fired when |
|---|---|
change.approved | A pending change is approved (manually or via auto-approve) |
change.applied | A change is written live to the page |
change.rejected | A pending change is rejected |
change.reverted | A live or approved change is rolled back |
change.pulled | A change is pulled by a CMS/platform integration for delivery |
change.verified | A pulled change is confirmed live by the integration |
report.completed | A website report finishes generating |
report.failed | A website report generation fails |
content_gap.found | A new content opportunity is detected |
wordpress.publish_success | A generated content piece publishes to WordPress |
wordpress.publish_failed | A WordPress publish attempt fails |
gbp.review_received | A new Google Business Profile review is fetched |
gbp.auto_reply_posted | An 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 isapprovedorlive. 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_htmlfor acontent_patch;keyword/urlfor an internal link;field/valuefor a meta or schema update).applyisnullfor 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:
| Header | Value |
|---|---|
X-SEOJuice-Signature | Hex-encoded HMAC-SHA256 of the raw request body, keyed with your endpoint’s secret |
X-SEOJuice-Event | The event type string, e.g. change.applied |
X-SEOJuice-Delivery-ID | A 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 parsingsignature = 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.