Prefer to publish somewhere other than WordPress - a headless CMS, a static-site build, Zapier, or your own service? Point SEOGraphy at a webhook. When you publish an article, we send a signed HTTP POST to your URL with the full article, and your system does the rest.
Your receiver only ever has to do one thing: take the article and act on it now. No delays, no queues, no date comparisons. Whether "act on it" means publishing it live or saving it as a draft is told to you in the payload, on every request - see "Draft or live" below. See "Scheduling" for why there is never anything to wait for.
Step 1 - Add the webhook
Open Sites, click your website, and scroll to the Publishing section.
Click Add webhook and enter the URL that will receive the POST request.
Save. SEOGraphy shows you a signing secret once (it starts with whsec_). Copy and store it now - you cannot see it again. To rotate it, remove the webhook and add it back.
Use the Test button any time to send a signed test ping. A test uses the event name webhook.test.
The test ping
The Test button sends a real, signed request to your URL so you can confirm the connection end to end before you publish anything. It is signed exactly like a publish and carries the same headers, with X-SEOGraphy-Event set to webhook.test.
A test ping has no `article` field. The whole body is:
So your receiver must check the event name first and return `200` immediately when it is webhook.test, before it validates or parses anything else:
if (payload.event === 'webhook.test') return res.status(200).end()
// ...only now validate the article payload
If you validate every request against the article contract instead, the test will fail with a 400 even though real publishes would be handled correctly - the test simply has nothing for that validation to accept. This is the single most common reason a working endpoint fails its own test.
Verify the signature before this check, not after: webhook.test is not a reason to trust an unsigned request.
The request we send
On each publish we send an HTTP POST with Content-Type: application/json and these headers:
Header
Value
X-SEOGraphy-Event
article.published
X-SEOGraphy-Timestamp
Unix time in seconds when we signed the request
X-SEOGraphy-Signature
Hex-encoded HMAC-SHA256 signature (see below)
X-SEOGraphy-Delivery-Id
Stable id for this article and this webhook (see "Avoiding duplicates")
publish_status tells you what to do with the article the moment it arrives. It is one of two values:
Value
What your receiver should do
publish
Put the post live now. This is the default and what most deliveries carry.
draft
Create the post but leave it unpublished, so someone on your side can check the copy and images and press live themselves.
Treat a missing or unrecognised value as `publish`. That keeps you compatible with deliveries sent before this field existed, and it matches what SEOGraphy does internally.
Whoever owns the website chooses this in SEOGraphy: there is a default for the whole site under Sites > your website > Publishing, and any single article can override it on the article's review screen. Teams that QA every post before it goes out set the site to drafts once and forget about it.
If your destination has no concept of a draft, you can ignore the field - but then leave the site set to Publish it live, so nobody is expecting a review step you cannot honour.
This is not scheduling.publish_status says *what* to do on arrival, never *when* to do it. A scheduled article is still held by SEOGraphy until its date and still delivered exactly once, at that moment. We never send an article early as a draft and follow it with a second "go live" call.
Images
Every article we generate comes with images, and they ship with the payload.
featured_image_url is the hero image, or null when the article has none. It is the field to map onto your CMS's cover / featured image. No-code tools (Zapier, Make, n8n) can point straight at it.
images is the full set, always ordered featured first, then inline_1, inline_2 and so on. Each entry has a position, a url, an alt text, the provider that rendered it, and its pixel width / height. Use the dimensions to reserve space in your layout so the page does not jump as images load. They are null on older articles - treat them as optional.
URLs are permanently hosted by us, so you can either hotlink them or download and re-host them on your own CDN. They do not expire.
An article whose images failed to generate simply arrives with featured_image_url: null and an empty images array. Handle both.
The hero is not in the body - the inline images are
This split is deliberate, and it means the simple integration is also the correct one:
The hero is only in `featured_image_url`. It is never inside body_markdown / body_html. Your CMS almost certainly has a featured-image field that renders above the post, so map it there. If we also put it in the body, every article would open with the same photo twice.
Inline images are embedded in the body, spread through the article one before each section after the intro. There is no field in your CMS for "the image that belongs between section 2 and section 3", so the body is the only place they can travel.
So the normal setup is: featured_image_url into your cover field, body_html into your content field. Nothing shows twice and nothing is lost.
If you would rather place every image yourself, use body_markdown_clean / body_html_clean instead. Those are the identical article with no images embedded at all, so you can position the whole images array however you like.
Social tags (Open Graph and Twitter Cards)
Every delivery carries ready-made social sharing tags under article.social, so a link to the published article shows a proper card on Facebook, LinkedIn, X, Slack, WhatsApp and iMessage rather than a bare blue link.
You do not have to use them. If your destination already generates its own tags - a WordPress site running Yoast or RankMath, for example, builds og:image from the featured image we send - just ignore this block. It exists for destinations that assemble their own head: headless CMSs, static-site builds, and custom receivers. We send the tags; what you do with them is your call.
The block ships in three shapes so you can take whichever fits your setup.
social.meta_tags - if you render the head yourself
A flat, ordered array where each entry is one tag, ready to write out. Loop it and you are done:
const head = payload.article.social.meta_tags
.map(t => `<meta ${t.attr}="${t.key}" content="${escapeHtml(t.content)}">`)
.join('\n')
attr matters. Open Graph tags are RDFa and use property=, Twitter Card tags use name=. Emitting them all as name= is the most common reason a hand-built head fails validation, so the payload tells you which is which instead of leaving you to remember.
Entries whose value we do not know are left out entirely rather than sent empty, so anything in the array is safe to render as-is. Always escape content for HTML before writing it into a page.
social.open_graph and social.twitter - if you map fields
The same values as plain keys with no colons, for no-code tools and CMS field maps where a key like og:title is awkward or illegal in a path expression.
Field
Notes
open_graph.type
Always article.
open_graph.title / twitter.title
The article title.
open_graph.description / twitter.description
The article's meta description. If it has none, we derive a short summary from the opening paragraph rather than send nothing.
open_graph.url
Canonical URL on your site. null when the website has no site URL set.
open_graph.site_name
The website's name in SEOGraphy.
open_graph.locale
Derived from the site's language, e.g. en_US. null when we cannot map it with confidence - fall back to your own site locale.
open_graph.image / twitter.image
The article hero. null when the article has no images.
open_graph.image_alt / twitter.image_alt
Alt text for the card image.
open_graph.image_width / image_height
Pixel size of the hero. null on older articles.
open_graph.image_type
MIME type, e.g. image/jpeg.
open_graph.published_time / modified_time
ISO 8601 timestamps.
open_graph.tags
Array of article tags. Today it holds the focus keyword.
twitter.card
summary_large_image when the article has a hero, otherwise summary.
Unlike meta_tags, these objects always contain every key, using null for anything unknown, so a field map never breaks on a missing path.
About the card image
The card image is the article's hero exactly as generated - we do not produce a separate, resized copy for social.
It renders at 16:9 while Open Graph's stated ideal is 1.91:1. That difference is small enough that Facebook, LinkedIn, X and Slack all accept the image and centre-crop it, and the URL is permanently hosted by us, which matters much more: social platforms cache a card for months, so an image URL that later disappears breaks every share of that article long after anyone would connect the two.
If you want a different crop, re-host the hero yourself and substitute your own og:image - all the other tags still apply.
What we deliberately do not send
twitter:site and twitter:creator need the website's own @handle, which SEOGraphy does not collect. Guessing one would credit the wrong account on every share, so those two tags are absent. Add them yourself if you want them.
Headings and the H1
The article body contains no `<h1>`. It opens with a paragraph and its first section heading is an <h2>.
That is deliberate: your CMS renders the post title as the page's H1 already. If the body carried one too, every published page would have two H1 tags - a basic on-page SEO problem, and the second one is usually just the title said again.
So map the fields the obvious way and the page comes out correct:
title into your post title field, which your theme renders as the H1.
body_html into your content field, which starts at H2.
Do not add a heading of your own around the body, and do not promote the first H2. If your CMS does *not* render the title as a heading anywhere on the page, then that is the one case where you should wrap title in an <h1> yourself.
Scheduling and publishDate
publishDate is an ISO 8601 timestamp of the moment the article went live in SEOGraphy. Map it onto your CMS's post date field.
SEOGraphy owns the scheduling. If a writer schedules an article for next Tuesday, we hold it and send nothing until then. We never send you a future-dated payload to sit on. When the scheduled time arrives our publisher releases the article and the webhook fires, so by the time a request reaches you there is nothing left to wait for.
In practice that means your receiver can act immediately on every request. You never need to queue, delay, or compare publishDate against your own clock to decide whether to hold something back. That is deliberate: a "Delay until" step in Zapier or a Wait node in n8n is exactly the kind of thing that silently breaks, and getting it wrong would put next week's article on your site today.
publishDate stays pinned to the original publish moment even if a delivery has to be retried later, so it is always safe to store as the post date.
Avoiding duplicates
Every request carries a delivery_id, both as the X-SEOGraphy-Delivery-Id header and in the body. It is stable for a given article and webhook: every retry of the same article to the same endpoint reuses the same value, and a different article always gets a different one.
If your endpoint is slow enough that we time out while it is still working, we will retry - and without a guard you would end up with two copies of the post. Record the delivery_id you have already handled and ignore repeats:
if (await alreadyHandled(deliveryId)) return res.status(200).end()
await createPost(payload.article)
await remember(deliveryId)
Most no-code tools can do this with a lookup step against a sheet or table before the create step.
Verify the signature
Always verify the signature before trusting a request. Compute an HMAC-SHA256 over the string timestamp + . + raw request body, using your signing secret, then hex-encode it and compare it to X-SEOGraphy-Signature. Use the raw request body exactly as received - do not re-serialise the JSON first.
const crypto = require('crypto')
// `rawBody` is the exact string of the request body (not the parsed object).
function isValid(headers, rawBody, secret) {
const timestamp = headers['x-seography-timestamp']
const signature = headers['x-seography-signature']
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex')
return signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
}
For extra safety, also reject requests whose X-SEOGraphy-Timestamp is more than a few minutes old, so an intercepted request cannot be replayed later.
Retries and timeouts
Respond with any 2xx status to acknowledge receipt.
Respond 2xx to a webhook.test event too, and do it before validating the body - it carries no article. See "The test ping" above.
Each attempt times out after 20 seconds. Return 200 quickly and do slow work (builds, imports) in the background.
If your endpoint does not return 2xx, we retry on a widening schedule: 30 seconds, then 2 minutes, 8 minutes, 30 minutes, and 2 hours. An endpoint that is down for a while will still receive the article once it recovers.
We stop after roughly three hours of trying. The article stays published in SEOGraphy and is marked Not delivered (or Partly delivered if some destinations took it), with the reason shown on the article. Fix your endpoint, then open the article and click Retry delivery to send it again.
We do not retry a 4xx response other than 408 or 429. A 401 or 404 means the request will never succeed as-is, so we surface it to you immediately rather than hammering your endpoint.
Retries only re-send to destinations that have not already accepted the article. If a site has both WordPress and a webhook and only the webhook failed, WordPress is not touched again.
Tips
The url field is your article's canonical URL (your site URL plus the slug). It is only present when the website has a site URL set.
body_html is ready to store or display; body_markdown is the source if you prefer to render it yourself. Neither contains an <h1> - see "Headings and the H1".
Default to publishing when publish_status is missing. A receiver that treats anything it does not recognise as a draft will quietly stop putting articles live.
social.meta_tags is the fastest way to get correct sharing cards: loop it, escape each content, and write the tags into your head. Skip the block entirely if your CMS already generates its own.
Publishing in SEOGraphy always succeeds even if your webhook is down. We record the delivery result and keep retrying, but never roll the article back.