Publishing setup

Finished posts publish straight to your site - or you publish them yourself. Pick what fits:

No integration at all. You publish each post yourself - on any CMS, site builder, or static site - and paste the live URL back so we can verify your links. Slower than auto-publish, but it works everywhere and needs zero setup.

  1. Pick manual mode: Settings → PublishingManual → save. There's nothing to connect or test.
  2. Review and approve posts under Posts → Needs review. Approved posts land on the Scheduled tab, waiting for you.
  3. Publish it on your site: open the post and hit Copy post HTML - it's ready to paste into any HTML-capable editor. Use the title and meta description shown on the post page.
  4. Mark as published: paste the live URL back on the post (or from the Scheduled tab). We fetch the page, verify the links, and re-check them daily from there.
Keep member links exactly as written, dofollow. Changing an anchor or href, dropping a link, or adding nofollow pauses your own backlinks - our daily monitor checks every live post.

Good to know

No code. We publish through the REST API built into every WordPress since 5.6, using an Application Password you can revoke any time. Nothing to install.

  1. In WordPress, as an Author/Editor/Admin: Users → Profile → Application Passwords, name it RankGoat, copy the password (shown once).
  2. In RankGoat: Settings → PublishingWordPress. Enter your site URL, username, and the application password.
  3. Test connection, then turn on auto-publish.

On publish we add images to your Media Library (hero as featured image), create the post (meta description as excerpt, categorized by format) with schema.org JSON-LD. Edits update the same post in place, matched by slug.

Keep member links dofollow. Plugins that add nofollow pause your own backlinks - our daily monitor checks.

Test fails?

Building with an AI agent? Copy the whole spec.
One click grabs the entire integration - security, every event, the full payload schema, examples and rules - as a single brief. Paste it into Claude Code, Cursor, or any coding agent for the best shot at a working endpoint first try.

Any other stack: host one HTTPS endpoint that accepts our signed JSON POSTs and branches on the event field - ping, media.upload, post.publish, post.update, post.delete. Full schema and examples below. New here? Build to the current contract version.

This is yours to choose - set whatever URL you build in Settings → Publishing. The examples below update to match it.

Every request

X-RankGoat-Event:     ping | media.upload | post.publish | post.update | post.delete
X-RankGoat-Timestamp: 1715900000          // unix seconds; reject if >5 min old
X-RankGoat-Signature: sha256=<hex>         // HMAC-SHA256(rawBody, RANKGOAT_SECRET)

Verify the signature against the raw body bytes (constant-time) before parsing. Respond with JSON within 30s. On any non-2xx or timeout we retry at +5 min and +1 hr, then email you.

Every request reaches you from one fixed IP: 178.105.170.30. If your endpoint sits behind Cloudflare, a WAF, or a firewall, allowlist it - see Blocked by a firewall or Cloudflare below.

Every field below is always present. Nullable = can be null; may be empty = empty string or array. Handle both.

ping

POSThttps://yoursite.com/api/rankgoat-publishevent=ping

Connectivity test. No request body beyond event and timestamp. Respond 200 with version - the contract version you implement (current is 2, which adds post.delete). We use it to know whether your integration is up to date and which events it can receive.

Response

{ "ok": true, "version": 2 }

media.upload

POSThttps://yoursite.com/api/rankgoat-publishevent=media.upload

Sent before any post with images. Store each file, return its public URL; we then rewrite the article to your URLs. Files you don't return are dropped.

Request body

eventstringRequired
"media.upload".
timestampintegerRequired
Unix seconds.
post_slugstringRequired
Slug of the post these images belong to.
mediaarrayRequired
One entry per image.
media[].filenamestringRequired
Use as the key in your response.
media[].content_typestringRequired
image/webp, image/png, or image/jpeg.
media[].data_base64stringRequired
Base64-encoded file bytes.

Response body

mediaarrayRequired
One entry per file you stored. Omit a file to drop it.
media[].filenamestringRequired
Echo the filename we sent.
media[].urlstringRequired
Public URL where you host it.

Request

{
  "event": "media.upload",
  "timestamp": 1715900000,
  "post_slug": "speed-up-your-static-site",
  "media": [
    { "filename": "post-42.webp", "content_type": "image/webp", "data_base64": "UklGR..." }
  ]
}

Response

{
  "media": [
    { "filename": "post-42.webp", "url": "https://yoursite.com/media/post-42.webp" }
  ]
}

post.publish

POSThttps://yoursite.com/api/rankgoat-publishevent=post.publish

Create the post, return its live URL. body_html and hero_image_url already point at the media URLs you returned.

Parameters

eventstringRequired
"post.publish", "post.update", or "post.delete".
timestampintegerRequired
Unix seconds. Reject if over 5 minutes old.
postobjectRequired
The article (fields below).
outbound_linksarrayMay be empty
Member links to embed. Keep each exactly; never add nofollow.
related_articlesarrayMay be empty
Links to your other posts, same topic cluster first. Strongly recommended to render (a "Related articles" list is the usual pattern): these internal links are how Google discovers and crawls the rest of your content.

post object

idintegerRequired
Our post id.
site_idintegerRequired
Our site id.
titlestringRequired
slugstringRequired
URL-safe, stable. Match on this for post.update.
meta_descriptionstringMay be empty
Use as the excerpt / meta description.
body_htmlstringRequired
Full HTML. Images already point at your media URLs.
hero_image_urlstring | nullNullable
Same image as the first <figure> in body_html (for a featured-image / og:image slot). null when there is no hero.
json_ldobjectRequired
schema.org BlogPosting + FAQPage. Inject verbatim into <head>.
languagestringRequired
BCP-47, e.g. en.
alternatesarrayMay be empty
Translations [{ lang, url }]. Empty today.
word_countinteger | nullNullable
categorystring | nullNullable
Format label (How-to, Comparison, ...).
tagsstring[]May be empty
Post tags.

outbound_links[]

target_domainstringRequired
e.g. techreview.io.
anchorstringRequired
Anchor text. Keep exactly.
hrefstringRequired
https://{target_domain}.

related_articles[]

titlestringRequired
urlstringRequired
Full URL to your post.
Hero image - don't show it twice. The hero is already the first <figure> in body_html; hero_image_url is the same image for a featured-image / og:image slot. If you set a featured image your theme renders above the content, strip that leading <figure> first. Never add hero_image_url as an extra <img>.

Request

{
  "event": "post.publish",
  "timestamp": 1715900000,
  "post": {
    "id": 42,
    "site_id": 7,
    "title": "Speed Up Your Static Site",
    "slug": "speed-up-your-static-site",
    "meta_description": "Five fixes that cut load time.",
    "body_html": "<figure><img src='https://yoursite.com/media/post-42.webp'></figure><p>...</p>",
    "hero_image_url": "https://yoursite.com/media/post-42.webp",
    "json_ld": { "@context": "https://schema.org", "@graph": [ { "@type": "BlogPosting" }, { "@type": "FAQPage" } ] },
    "language": "en",
    "alternates": [],
    "word_count": 1180,
    "category": "How-to",
    "tags": ["static sites", "performance"]
  },
  "outbound_links": [
    { "target_domain": "techreview.io", "anchor": "build pipelines", "href": "https://techreview.io" }
  ],
  "related_articles": [
    { "title": "Lazy-Loading Images", "url": "https://yoursite.com/blog/lazy-loading-images" }
  ]
}

Response

{ "published_url": "https://yoursite.com/blog/speed-up-your-static-site" }

post.update

POSThttps://yoursite.com/api/rankgoat-publishevent=post.update

Sent when a member edits a live post. Identical payload to post.publish - match on post.slug and update in place (don't duplicate or change the URL). If the slug is unknown, create it.

Response

{ "published_url": "https://yoursite.com/blog/speed-up-your-static-site" }

post.delete

POSThttps://yoursite.com/api/rankgoat-publishevent=post.delete

Sent when a member archives a live post - remove it from your site. Minimal payload (no body or links); match on post.slug and delete or unpublish it. Idempotent: if it's already gone, still respond 200. A later restore re-sends it as a normal post.update, so trashing rather than hard-deleting is fine.

Request

{
  "event": "post.delete",
  "timestamp": 1715900000,
  "post": { "id": 123, "slug": "my-post-slug" }
}

Response

{ "ok": true }

Languages

Each page is self-canonical (canonical points at its own URL). When alternates is non-empty, emit reciprocal <link rel="alternate" hreflang="..."> tags plus an x-default. It's [] until translations exist.

Handler

import express from "express";
import crypto from "node:crypto";

const app = express();
app.use(express.raw({ type: "application/json" })); // raw body so HMAC matches

const SECRET = process.env.RANKGOAT_SECRET;

app.post("/api/rankgoat-publish", async (req, res) => {
  const sig = req.get("X-RankGoat-Signature") || "";
  const expected = "sha256=" + crypto.createHmac("sha256", SECRET).update(req.body).digest("hex");
  if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
    return res.status(401).json({ error: "bad signature" });
  }

  const payload = JSON.parse(req.body.toString("utf8"));
  if (payload.event === "ping") return res.json({ ok: true, version: 2 }); // version = contract you implement

  if (payload.event === "media.upload") {
    const media = await Promise.all(payload.media.map(async (m) => ({
      filename: m.filename,
      url: await saveToStorage(m.filename, Buffer.from(m.data_base64, "base64")),
    })));
    return res.json({ media });
  }

  // post.publish and post.update share this shape; upsert on slug.
  if (payload.event === "post.publish" || payload.event === "post.update") {
    const post = payload.post;
    await db.posts.upsert({ slug: post.slug }, {
      title: post.title,
      body_html: post.body_html,
      meta_description: post.meta_description,
      json_ld: JSON.stringify(post.json_ld), // print in <head> as <script type="application/ld+json">
    });
    return res.json({ published_url: `https://yoursite.com/blog/${post.slug}` });
  }

  // post.delete: member archived the post - remove it by slug. Idempotent.
  if (payload.event === "post.delete") {
    await db.posts.deleteBySlug(payload.post.slug); // no-op if already gone
    return res.json({ ok: true });
  }

  res.json({ ok: true }); // ignore events you don't recognise (forward-compatible)
});

app.listen(8080);
import hmac, hashlib, os, json, base64
from flask import Flask, request, jsonify

app = Flask(__name__)
SECRET = os.environ["RANKGOAT_SECRET"].encode()

@app.post("/api/rankgoat-publish")
def rankgoat_publish():
    body = request.get_data()
    sig  = request.headers.get("X-RankGoat-Signature", "")
    expected = "sha256=" + hmac.new(SECRET, body, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, sig):
        return jsonify(error="bad signature"), 401

    payload = json.loads(body)
    if payload["event"] == "ping":
        return jsonify(ok=True, version=2)  # version = contract you implement

    if payload["event"] == "media.upload":
        media = [
            {"filename": m["filename"], "url": save_to_storage(m["filename"], base64.b64decode(m["data_base64"]))}
            for m in payload["media"]
        ]
        return jsonify(media=media)

    # post.delete: member archived the post - remove it by slug. Idempotent.
    if payload["event"] == "post.delete":
        your_cms.delete_by_slug(payload["post"]["slug"])  # no-op if already gone
        return jsonify(ok=True)

    # post.publish and post.update share this shape; upsert on slug.
    post = payload["post"]
    your_cms.upsert_post(
        slug=post["slug"],
        title=post["title"],
        body=post["body_html"],
        meta=post["meta_description"],
        json_ld=post["json_ld"],
    )
    return jsonify(published_url=f"https://yoursite.com/blog/{post['slug']}")

Ghost can't receive webhooks - point the Node handler at Ghost's Admin API.

Rules

Appendix

Blocked by a firewall or Cloudflare? (403)

If you only see some events (for example post.publish but never media.upload), or a published post arrives with no image, your edge is most likely blocking us. media.upload carries the image and has a larger body, so a WAF or bot rule often blocks it while letting smaller events through. The tell is a 403 whose body is an HTML challenge page (not your app's JSON) - that means the request never reached your server.

Fix: allowlist our publishing IP 178.105.170.30 so our requests skip your bot/WAF rules:

After allowlisting, open the post in Posts and use Re-send to deliver it again. A 413 means a body-size limit (raise it); a 401 means the signature/secret - re-check your secret in Settings → Publishing.

Signature rejected (401)

The secret your endpoint verifies with doesn't match the one we sign with. Copy the secret from Settings → Publishing into your endpoint's environment, redeploy, then run Test connection and re-send the post.

Endpoint returns 404

The webhook URL doesn't match a route on your server. Double-check the full URL (including the path) in Settings → Publishing, and make sure the route accepts POST.

Your endpoint errored (HTTP 5xx)

The request reached your endpoint, but your code threw while handling it - the fix is on your side, and your server or function logs have the stack trace. Common causes: verifying the signature against the parsed body instead of the raw bytes, a failing database write, or an unhandled event type (respond 200 to any event you don't recognise so new event types never break you). Once fixed, re-send the post from Posts.

Endpoint unreachable or timing out

We couldn't connect, or your endpoint took longer than 30 seconds to respond. Make sure it's deployed, publicly reachable over HTTPS (not localhost, not behind auth), and responds fast - acknowledge with 200 first and do slow work afterwards. Then re-send the post.

Replied with non-JSON

Your endpoint must answer 200 with a JSON body, e.g. {"ok":true}. An HTML error page, a redirect, or an empty body counts as a failed delivery. Check what your route actually returns (a proxy or error middleware may be answering instead of your handler), then re-send.

Contract versions

Report the version you implement from your ping: { "ok": true, "version": 2 }. RankGoat uses it to know which events you can receive. Current version: 2.

Upgrading v1 → v2: add a post.delete handler (delete/unpublish by slug, respond 200, idempotent), then return "version": 2 from ping and re-run Test connection. Until your ping reports v2, we won't send post.delete - archived posts stay on your site and you remove them manually. Tip: respond 200 to any event you don't recognise so future versions never break you.