
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 today, credited to your first payment.
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, with a key that has the chat:ask scope on a workspace on the Infrastructure plan. A workspace can have up to 25 endpoints. Each call 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
POST /v1/webhooks/{id}/rotate-secret new secret; the old one keeps signing for 24h
DELETE /v1/webhooks/{id} remove oneSubscribe to all events (default) or a subset. Rotating the secret returns the new one once, with the time the old one stops signing:
{
"id": "<webhook id>",
"secret": "whsec_...",
"previousSecretValidUntil": "2026-09-25T12:00:00.000Z"
}X-Seomatic-Delivery id.PATCH /v1/webhooks/{id} with {"enabled": true} turns it back on and clears the failure count.410 Gone to unsubscribe: the endpoint is deleted and not retried.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 | The delivery id. It stays the same across retries of one event, so use it to skip an event you already processed. |
User-Agent | SEOmatic-Webhooks/1 |
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";
// secret = the whsec_... shown once at endpoint creation. Verify the RAW
// bytes, never re-serialized JSON: in Express, mount the route with
// express.raw({ type: "application/json" }) so req.body is the exact Buffer.
//
// app.post("/hooks/seomatic", express.raw({ type: "application/json" }),
// (req, res) => {
// if (!verify(req, process.env.SEOMATIC_WHSEC)) return res.sendStatus(400);
// const event = JSON.parse(req.body.toString("utf8"));
// res.sendStatus(200); // answer 2xx within 10 seconds
// });
export function verify(req, secret, toleranceSec = 300) {
// "t=...,v1=..." - during a secret rotation there is one v1 per active
// secret ("t=...,v1=<new>,v1=<old>"): accept the event if ANY v1 matches.
const sig = req.headers["x-seomatic-signature"];
const ts = Number(req.headers["x-seomatic-timestamp"]);
const body = req.body.toString("utf8"); // 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 candidates = [...(sig || "").matchAll(/v1=([a-f0-9]+)/g)].map(m => m[1]);
return candidates.some(
got =>
got.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(got), Buffer.from(expected))
);
}
// Rotating the secret: POST /api/v1/webhooks/{id}/rotate-secret returns the
// new whsec_... once. For 24 hours every delivery is signed with BOTH secrets,
// so deploy the new one any time in that window without dropping events.
// Rotating again inside the window replaces the older secret immediately.import hashlib, hmac, re, time
def verify(headers, raw_body: bytes, secret: str, tolerance_sec: int = 300) -> bool:
sig = headers.get("X-Seomatic-Signature", "")
try:
ts = int(headers.get("X-Seomatic-Timestamp", ""))
except ValueError:
return False
# 1) Reject a stale timestamp (replay guard).
if abs(time.time() - ts) > tolerance_sec:
return False
# 2) Recompute HMAC-SHA256 over f"{ts}.{body}" and constant-time compare.
# During a rotation there is one v1 per active secret: accept ANY match.
expected = hmac.new(
secret.encode(), f"{ts}.".encode() + raw_body, hashlib.sha256
).hexdigest()
return any(
hmac.compare_digest(got, expected)
for got in re.findall(r"v1=([a-f0-9]+)", sig)
)
# Flask: verify(request.headers, request.get_data(), os.environ["SEOMATIC_WHSEC"])Every delivery shares one envelope: event, workspace_id, created_at, and a data object whose fields depend on the event, documented below.
A 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"
}
}A 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"
}
}An 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
}
}The agent staged work that needs a human decision. One event per task, at most 10 per staging batch: the approval board in the app always holds the full list. Pair with the REST decide_seo_task tool (or the Zapier Decide SEO Task action) to approve or dismiss from wherever the event lands.
| Field | Type | Description |
|---|---|---|
data.task_id | string (uuid) | |
data.type | string | null | e.g. content_create, ctr_fix |
data.title | string | null | the goal, in plain words |
data.target | string | null | page URL or topic |
data.approve_in_app | string (url) |
{
"event": "task.awaiting_approval",
"workspace_id": "…",
"created_at": "2026-08-28T12:00:00.000Z",
"data": {
"task_id": "77b4…",
"type": "content_create",
"title": "Cover a topic your audience searches for",
"target": "programmatic seo guide",
"approve_in_app": "https://app.seomatic.ai/dashboard/agents"
}
}Every task in a campaign reached a terminal state.
| Field | Type | Description |
|---|---|---|
data.campaign_id | string (uuid) | |
data.type | string | topic_cluster, refresh_sweep, ctr_sweep, technical_pass, internal_link_architecture, single_fix, page_scale, content_sweep or 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"
}
}An AI-visibility scan finished, whether you started it or it ran on its schedule.
| Field | Type | Description |
|---|---|---|
data.scan_id | string (uuid) | |
data.brand | string | the brand name the scan looked for |
data.per_engine | object[] | one entry per engine: engineId, label, promptsChecked, mentions, visibility (mentions / promptsChecked, 0 to 1), competitors [{ name, count }], sources [{ url, count }] |
data.credits_charged | integer | |
data.lost_answers | object[] | up to 15 answers that mentioned you last scan and no longer do: { prompt, engineId, competitors }, where competitors are the names now in that answer |
data.gained_answers | object[] | up to 15 answers that mention you now and did not last scan, same shape |
{
"event": "ai_visibility.scan_completed",
"workspace_id": "…",
"created_at": "2026-08-28T12:00:00.000Z",
"data": {
"scan_id": "e5f6…",
"brand": "Acme",
"per_engine": [
{
"engineId": "openai",
"label": "ChatGPT",
"promptsChecked": 10,
"mentions": 4,
"visibility": 0.4,
"competitors": [
{
"name": "Globex",
"count": 6
}
],
"sources": [
{
"url": "g2.com",
"count": 3
}
]
}
],
"credits_charged": 6667,
"lost_answers": [
{
"prompt": "best crm for small agencies",
"engineId": "openai",
"competitors": [
"Globex"
]
}
],
"gained_answers": []
}
}