
Each local area page used to take us half a day to create and optimize.
With SEOmatic, we can create hundreds of pages in the same time, which helps our clients make the best use of their budget.
It's transformed how we deliver scalable SEO solutions.
Will Hawkins
Marketing Director, Digi-Business UK
Agents read your Search Console data, do the work, and prove what actually moved. You decide what ships.
14-Day Free Trial. $1 card check, refunded. Cancel Anytime.
Register HTTPS endpoints that receive a signed POST the moment something happens in your workspace. Available on the Infrastructure plan.
Register and manage endpoints in the dashboard or over the REST API. Each is a full endpoint in the REST reference:
GET /v1/webhooks list your endpoints
POST /v1/webhooks register one (secret returned once)
PATCH /v1/webhooks/{id} enable/disable, change events
DELETE /v1/webhooks/{id} remove oneSubscribe to all events (default) or a subset. Delivery is retried with exponential backoff; an endpoint that keeps failing is auto-disabled, and re-enabling it clears the failure count.
Every delivery is a POST with these headers:
| Header | Meaning |
|---|---|
X-Seomatic-Event | The event name, e.g. page.published. |
X-Seomatic-Signature | t=<unix>,v1=<hmac-sha256 hex> over `${timestamp}.${body}`. |
X-Seomatic-Timestamp | Unix seconds the delivery was signed. Reject if stale. |
X-Seomatic-Delivery | A unique id for this delivery attempt (for idempotency). |
Recompute the HMAC-SHA256 over `${timestamp}.${body}` with your endpoint's signing secret, constant-time compare it to X-Seomatic-Signature, and reject a stale X-Seomatic-Timestamp to defeat replay:
import crypto from "node:crypto";
// Express-style handler. secret = the whsec_... shown once at endpoint creation.
export function verify(req, secret, toleranceSec = 300) {
const sig = req.headers["x-seomatic-signature"]; // "t=...,v1=..."
const ts = Number(req.headers["x-seomatic-timestamp"]);
const body = req.rawBody; // the EXACT bytes received
// 1) Reject a stale timestamp (replay guard).
if (!ts || Math.abs(Date.now() / 1000 - ts) > toleranceSec) return false;
// 2) Recompute HMAC-SHA256 over `${ts}.${body}` and constant-time compare.
const expected = crypto
.createHmac("sha256", secret)
.update(`${ts}.${body}`)
.digest("hex");
const got = /v1=([a-f0-9]+)/.exec(sig || "")?.[1] || "";
return (
got.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(got), Buffer.from(expected))
);
}Every delivery shares one envelope: event, workspace_id, created_at, and a data object whose fields depend on the event, documented below.
page.publishedA page went live on the customer's site.
| Field | Type | Description |
|---|---|---|
data.page_id | string (uuid) | |
data.project_id | string (uuid) | |
data.url | string | the live URL |
data.cms_item_id | string | id in the customer's CMS |
data.published_at | string (ISO 8601) |
{
"event": "page.published",
"workspace_id": "…",
"created_at": "2026-08-28T12:00:00.000Z",
"data": {
"page_id": "b1e2…",
"project_id": "a9c0…",
"url": "https://acme.com/compare/x-vs-y",
"cms_item_id": "1042",
"published_at": "2026-08-28T12:00:00.000Z"
}
}page.publish_failedA page failed to publish.
| Field | Type | Description |
|---|---|---|
data.page_id | string (uuid) | |
data.project_id | string (uuid) | |
data.error | string | failure reason (truncated) |
data.failed_at | string (ISO 8601) |
{
"event": "page.publish_failed",
"workspace_id": "…",
"created_at": "2026-08-28T12:00:00.000Z",
"data": {
"page_id": "b1e2…",
"project_id": "a9c0…",
"error": "CMS returned 401 (auth expired)",
"failed_at": "2026-08-28T12:00:00.000Z"
}
}task.completedAn SEO agent task finished executing.
| Field | Type | Description |
|---|---|---|
data.task_id | string (uuid) | |
data.type | string | e.g. ctr_fix, alt_text |
data.target | string | the page/entity it acted on |
data.applied | boolean | did a change land live |
{
"event": "task.completed",
"workspace_id": "…",
"created_at": "2026-08-28T12:00:00.000Z",
"data": {
"task_id": "77b4…",
"type": "ctr_fix",
"target": "https://acme.com/pricing",
"applied": true
}
}campaign.completedEvery task in a campaign reached a terminal state.
| Field | Type | Description |
|---|---|---|
data.campaign_id | string (uuid) | |
data.type | string | page_scale, content_sweep, bulk_edit |
data.tasks_total | integer | |
data.tasks_succeeded | integer | |
data.completed_at | string (ISO 8601) |
{
"event": "campaign.completed",
"workspace_id": "…",
"created_at": "2026-08-28T12:00:00.000Z",
"data": {
"campaign_id": "c3d4…",
"type": "page_scale",
"tasks_total": 120,
"tasks_succeeded": 118,
"completed_at": "2026-08-28T12:00:00.000Z"
}
}ai_visibility.scan_completedAn AI-visibility scan finished.
| Field | Type | Description |
|---|---|---|
data.scan_id | string (uuid) | |
data.brand | object | brand mention summary |
data.per_engine | object[] | per-engine visibility |
data.credits_charged | integer |
{
"event": "ai_visibility.scan_completed",
"workspace_id": "…",
"created_at": "2026-08-28T12:00:00.000Z",
"data": {
"scan_id": "e5f6…",
"brand": {
"mentioned": true,
"share": 0.42
},
"per_engine": [
{
"engine": "chatgpt",
"visibility": 0.5
}
],
"credits_charged": 6667
}
}