Connect your site to SiteOps

Every other connection in SiteOps brings numbers in. This one carries content out. The publishing webhook is one small address on your own site where SiteOps delivers each blog post you approved, and your site puts it live under your own domain.

The contract is small and exact. For every publish, SiteOps sends one POST with the post as JSON — its slug, title, ready-made html and publish time, plus its page head, images and languages when it has them — signed with a secret only you and SiteOps hold. Your site checks the signature, stores the post and answers with the page's address. SiteOps then reads that address until the post's title is on the page.

Two things stay yours: the page itself, which your site wraps in its own layout, and telling the search engines about it — SiteOps does not ping IndexNow or Google for your site. It is the mirror image of the Push API: that is your server talking to SiteOps, this is SiteOps talking to your server. Set it up once with whoever maintains your site.

SiteOps delivers, your site answers with the address

What every delivery carries

One POST per publish, to the address you saved in Settings. The body is JSON, and these three headers are the ones that matter.

HeaderWhat it holds
Content-Typeapplication/json — the body is UTF-8 JSON.
X-SiteOps-Signaturesha256= followed by the hex HMAC-SHA256 of the raw request body, keyed with your whsec_ secret.
X-SiteOps-DeliveryOne id per delivery — the same on its retry, a new one for every edit. The next delivery of the same page carries a new id.

The fields

Six fields are in every delivery. The rest are sent only when they have a value — your site may use them or ignore them.

FieldSentWhat it holds
typealways"post" — every delivery today is a blog post.
slugalwaysThe page's address segment, e.g. first-note. It never changes after the first publish.
titlealwaysThe article's headline — the page's h1, and what SiteOps looks for when it reads the page back.
htmlalwaysThe article body as clean HTML — no scripts, styles or iframes; link and image addresses are http(s) or root-relative (/…) only — ready for your post template. A picture with a line under it arrives as <figure> holding the <img> and a <figcaption> — style both in your template. A number in the text says who measured it and links the page it came from; a link to another site carries rel="nofollow noopener", links to your own pages stay followed.
publish_atalwaysWhen SiteOps sent this delivery, in UTC, e.g. 2026-09-30T09:00:00Z. A scheduled post is sent at its time, so on the FIRST delivery of a slug and locale show the post as soon as it is stored and use this as its date. A later delivery of the same page (an edit, a 'read next' link added) carries the time of that send — keep the date you stored, and use this one as the page's modified time if you show one.
eventalways"post_published"
markdownwhen setThe same body as Markdown, for sites that store Markdown.
subtitlewhen setThe line under the title.
localewhen setThe page's language as your site spells it, e.g. en or uk.
translation_groupwhen setOne id shared by every language version of the same post.
canonicalwhen setA canonical you set by hand; the page's own address rides in seo.canonical.
assetswhen setThe article's images at permanent addresses: [{url, alt, role}], role banner or body, banner first. Mirror them or link to them — the addresses never expire.
alternateswhen setThe post's other languages that are already live: [{locale, url}] — your hreflang links.
seowhen setThe page's whole head — see the next table.

The page head, ready to use

The seo field is everything the page's <head> needs to be found and shared, written for this post in its own language.

KeyWhat to do with it
titleThe search results title → <title>.
descriptionThe search results line → <meta name="description">.
keywordsA list of search phrases → <meta name="keywords">, comma-joined.
canonicalPresent once the address is known → <link rel="canonical">.
og{type, title, description, site_name, locale, url?, image?, image_alt?} → one <meta property="og:…"> per key; image_alt becomes og:image:alt.
twitter{card, title, description, image?} → one <meta name="twitter:…"> per key.
json_ldBlogPosting structured data → <script type="application/ld+json">, as it arrives.
alternates[{locale, url}], only when other languages are live → <link rel="alternate" hreflang="…">.
  • Store it with the page on every upsert that carries one, replacing the head you had. A delivery without seo leaves the stored head as it is — never blank it.
  • A first publish has no canonical and no og.url yet — SiteOps learns the address from your answer, and every later delivery of the page carries it.

One page, one identity — and repeats

A page is its slug and its locale together. The same post in two languages is two pages, joined by one translation_group. The slug never changes after the first publish — when a title is edited, the page keeps its address.

A delivery for a slug and locale you already have is an edit: update that page in place, keeping its date, and answer its url. That is how changes made in SiteOps reach your site.

Deduplicate by X-SiteOps-Delivery and nothing else. Each publish carries a fresh id, and its retries carry the same one. Seen that id before? Answer 200 with the url and "duplicate": true, and change nothing. A slug you already have under a new id is not a duplicate — answering duplicate there would quietly drop the edit.

What your answer means

SiteOps reads your answer, and what it does next depends on it. Each attempt gets 20 seconds.

Your answerWhat SiteOps does
200 {"url": "https://…"}Delivered. The url is the page's public address — SiteOps keeps it and reads the page back there. Without a url nothing can be confirmed live. (A canonical or published_url key is read the same way.)
200 {"url": "…", "duplicate": true}You had already stored this delivery — a success, and nothing happens twice. A 409 whose body says duplicate means the same.
401 or 403The signature did not match your secret. Not retried: the publish fails, and SiteOps asks you to make a New signing secret in Settings and put it on your site.
Any other 4xxYour site refused the request as wrong. Not retried: the publish fails with the status you answered.
A redirect (3xx)Not followed. Counted as a failure, retried once at the same address, then the publish fails with that status — save the final address in Settings.
5xx, a timeout or no answerRetried once, straight away, with the same body and the same X-SiteOps-Delivery id.
5xx with Retry-After: 5SiteOps waits the seconds you asked for — up to 10 — before the retry. Sent on the retry, it earns one more attempt: the answer for a delivery your site is still storing.
  • Store the post and answer inside the 20 seconds. Anything slow — resizing images, a full site rebuild, telling search engines — belongs after the answer.
  • After the last attempt the publish is marked failed, with what your site answered as the reason. It is not sent again on its own.

After you answer: the live check

Your 200 says the site accepted the post. It does not yet say a reader can see it — a build may still be running, a cache may still hold the old page. So SiteOps reads the url you answered, the way a visitor's browser would, until it answers 200 with the post's title on the page.

It checks a few times in the first couple of minutes, then every 15 minutes. Only once the title is found does the post read published in SiteOps. After 24 hours without it, SiteOps stops checking and says why.

  • The title has to be in the HTML your server sends. A page that draws its content with JavaScript after loading never shows it to the check — render the post on the server.

Indexing is yours

SiteOps does not ping IndexNow or Google for your site. Your site knows the moment a page goes live, so it is the one to say so: list the page in your sitemap, send its address to IndexNow, submit it in Search Console — whichever your site already does. Do it after you answer; the live check does not wait for it.

Check the signature

Compute sha256= and the hex HMAC-SHA256 of the raw body with your whsec_ secret, over the bytes exactly as they arrived — before any JSON parsing — and compare in constant time. Both samples also answer a repeated delivery as a duplicate.

Node.js
// app/siteops-webhook/route.js — a Next.js route handler (Node runtime)
import crypto from "node:crypto";

const SECRET = process.env.SITEOPS_WEBHOOK_SECRET; // whsec_…

function signatureOk(raw, header) {
  const expected =
    "sha256=" + crypto.createHmac("sha256", SECRET).update(raw).digest("hex");
  const a = Buffer.from(header || "");
  const b = Buffer.from(expected);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

export async function POST(request) {
  // the exact bytes that arrived — check BEFORE parsing
  const raw = Buffer.from(await request.arrayBuffer());
  if (!signatureOk(raw, request.headers.get("x-siteops-signature"))) {
    return Response.json({ error: "bad signature" }, { status: 401 });
  }
  const delivery = request.headers.get("x-siteops-delivery");
  const post = JSON.parse(raw.toString("utf8"));

  const seen = await findDelivery(delivery); // your storage
  if (seen) return Response.json({ url: seen.url, duplicate: true });

  // create or update by (slug, locale); keep post.seo with the page
  const url = await upsertPost(post); // your storage → the page's public address
  await rememberDelivery(delivery, url);
  return Response.json({ url });
}
Python
# Flask — the check itself is the same in any Python framework
import hashlib, hmac, json, os
from flask import Flask, request

app = Flask(__name__)
SECRET = os.environ["SITEOPS_WEBHOOK_SECRET"].encode()  # whsec_…

def signature_ok(raw: bytes, header: str) -> bool:
    expected = "sha256=" + hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected.encode(), (header or "").encode())

@app.post("/siteops-webhook")
def siteops_webhook():
    raw = request.get_data()  # the exact bytes, before any JSON parsing
    if not signature_ok(raw, request.headers.get("X-SiteOps-Signature", "")):
        return {"error": "bad signature"}, 401
    delivery = request.headers.get("X-SiteOps-Delivery")
    post = json.loads(raw)

    seen = find_delivery(delivery)  # your storage
    if seen:
        return {"url": seen.url, "duplicate": True}

    # create or update by (slug, locale); keep post["seo"] with the page
    url = upsert_post(post)  # your storage → the page's public address
    remember_delivery(delivery, url)
    return {"url": url}

A sample delivery and its answer

A real delivery, byte for byte, signed with the throwaway secret whsec_example_only_never_use_this_one. Point your check at it: it must pass — and fail if you change one byte of the body. The body is compact JSON with its keys sorted; take it as it arrives, never rebuild it.

SiteOps sends
POST /siteops-webhook HTTP/1.1
Host: yoursite.com
Content-Type: application/json
X-SiteOps-Signature: sha256=922a3e7c1f54eb73aa8eba6c08269b8c567b588328152a2094eb96b7b113710c
X-SiteOps-Delivery: 5b0e2c1a-8f3d-4e6b-9a27-3c4d5e6f7a81

{"event":"post_published","html":"<p>Hello from SiteOps.</p>","locale":"en","publish_at":"2026-09-30T09:00:00Z","slug":"first-note","title":"Our first note","type":"post"}
Your site answers
HTTP/1.1 200 OK
Content-Type: application/json

{"url":"https://yoursite.com/blog/first-note"}
The same delivery again
HTTP/1.1 200 OK
Content-Type: application/json

{"url":"https://yoursite.com/blog/first-note","duplicate":true}

Quick links

What SiteOps reads

  • This connection mostly writes: SiteOps sends the posts you approved — the fields above — and nothing else. No tracking code, no plugins, no changes to pages it did not send.
  • It reads back only the public page at the address your site answered, the way a visitor's browser would, to confirm the post is live.
  • Nothing travels without passing your review rules. At low autopilot levels that is you approving each post; at higher levels it is the trust you explicitly granted — ads and payments are never part of this contract.
  • Payments stay untouched everywhere. SiteOps never writes to a billing tool or weaves offers into your copy.
  • Removing the webhook stops all publishing to your site immediately. Pages already published are yours — they live on your domain and stay there.

Set it up, step by step

0 of 9 done

    1. Settings
    2. Publishing
    1. Posts
    2. Calendar
    1. Settings
    2. Publishing

Let an agent do it

This is the most agent-friendly setup in SiteOps: an agent with access to your site's code can build the whole endpoint for you.

Prompt for your agent
Implement the SiteOps publishing webhook on my site (stack: <e.g. Next.js app router, WordPress, Rails>). The full contract is at https://siteops.me/guides/site-webhook.

1. Add a POST route at /siteops-webhook on the live, public domain, over https. SiteOps never follows a redirect, so the route must answer at the exact address saved in SiteOps (no http→https, apex→www or trailing-slash redirect in front of it).
2. Verify the X-SiteOps-Signature header before parsing anything: it is "sha256=" plus the hex HMAC-SHA256 of the raw request body, keyed with the whsec_ secret in the environment variable SITEOPS_WEBHOOK_SECRET. Compare in constant time; on a mismatch answer 401 and store nothing.
3. Deduplicate by the X-SiteOps-Delivery header only: if that id was stored before, answer 200 {"url": <the page's url>, "duplicate": true} and change nothing.
4. Parse the JSON. Always present: type ("post"), slug, title, html, publish_at, event ("post_published"). Optional: markdown, subtitle, locale, translation_group, canonical, assets [{url, alt, role}], alternates [{locale, url}], seo.
5. Create or update the post by (slug, locale) — an existing slug and locale is an edit of that page. On a create, use publish_at as the post's date; on an update keep the stored date (publish_at is then the modified time). Wrap html in the site's post template, and style <figure> and <figcaption> there (a picture with a line under it arrives as one). Store seo with the page whenever a delivery carries it — a delivery without seo keeps the stored head, never blank it — and render it into <head>: title, meta description, meta keywords, canonical, og:* tags (image_alt as og:image:alt), twitter:* tags, json_ld as an application/ld+json script, alternates as hreflang links.
6. Answer 200 {"url": "<the page's public address>"} within 20 seconds. Do slow work after answering — image processing, rebuilds, and the sitemap and IndexNow updates the site owns. If a retry arrives while the same delivery is still being stored, answer 503 with Retry-After: 5.
7. The site owns indexing: add the page to the sitemap and ping IndexNow once it is live — SiteOps does not.
8. Test the signature check with the sample delivery on the guide page: it must pass, and fail when one byte of the body changes.

You still paste the endpoint URL into SiteOps settings, copy the whsec_ secret it shows once into your site's configuration, and approve the posts that travel.

If something does not work

My endpoint rejects every request with a signature mismatch
Three usual causes, in order. Your code checks a re-serialized copy of the JSON instead of the raw bytes it received — check before parsing. The secret in your configuration is stale — if anyone pressed New signing secret, the old whsec_ value is dead. Or a proxy in front of your site rewrites the body — compression and automatic JSON formatting both break the signature. The sample delivery above is a quick test: your check must accept it with the sample secret.
SiteOps says my webhook address is refused
SiteOps delivers only to public addresses. A URL that points at localhost, a private network (10.x, 192.168.x and the like) or an internal hostname is refused before anything is sent, and not retried. SiteOps never follows a redirect either: an endpoint that answers 301/302/307/308 fails as 'webhook answered 30x' (retried once at the same address, then failed) — save the final address in Settings. Use the public https address of your live site.
Deliveries time out even though the page does get created
Each attempt gets 20 seconds. If your endpoint rebuilds the whole site before it answers, the answer comes too late, and SiteOps retries with the same X-SiteOps-Delivery id. Flip the order: store the post, answer, then build. If a retry arrives while the first attempt is still storing, answer 503 with Retry-After: 5 — SiteOps waits and tries once more, and then you answer the stored page as a duplicate.
The post was sent, but it never reads live
SiteOps reads the page at the url you answered and looks for the post's title there. Check three things: that your answer carried a url at all, that the url is where the page really lives, and that the title is in the HTML your server sends — a page drawn by JavaScript after loading never shows it. After 24 hours SiteOps stops checking and says what it last found.
I edited a post in SiteOps and my page did not change
Your endpoint answered the edit as a duplicate. A delivery is a duplicate only when its X-SiteOps-Delivery id is one you stored before — an edit comes with a new id and the same slug and locale, and it must update the page.
A retry created the same post twice
Your endpoint treats every delivery as brand new. Look the X-SiteOps-Delivery id up before storing, and upsert by slug and locale instead of inserting. Then a retry is a duplicate, an edit is an update, and nothing appears twice.
Is this the same thing as the Push API key?
No, and the two do not interchange. The Push API key (it starts with sk_push_) lets your server write numbers into SiteOps. The webhook secret (it starts with whsec_) lets SiteOps write content to your site. Opposite directions, different credentials — a push key pasted into your webhook configuration verifies nothing, and the other way round.

Set up once, see everything every morning

Get started