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.
Security, every event, the full schema and examples in one brief. Paste it into Claude Code, Cursor, or any coding agent.

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": 3 }

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.
testbooleanv3
true when this is the connection test's image. Store it or answer with any URL - the post it belongs to is discarded either way. See the test flag.
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.
testbooleanv3
true means a connection check: answer normally with the published_url you would have used, then discard it - don't save, publish, or announce it. See the test flag.
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: 3 }); // 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;
    // v3: a connection check. Answer as normal, but don't save or expose it.
    if (payload.test) {
      return res.json({ published_url: `https://yoursite.com/blog/${post.slug}` });
    }
    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:

  • Cloudflare: Security → WAF → Custom rules (or Tools → IP Access Rules): add a Skip / Allow rule for IP 178.105.170.30 on your endpoint's path. Also exclude it from Bot Fight Mode.
  • nginx / Apache / firewall: allow 178.105.170.30 to your endpoint path, and make sure your request body size limit (e.g. nginx client_max_body_size) accepts a few MB - images are sent inline.
  • Vercel / Netlify / serverless: add the IP to any firewall/bot-protection allowlist, and confirm your function's body-size limit isn't rejecting the image.

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.

The test flag

Every event carries a top-level test boolean. When it's true, the payload is a connection check, not real content:

{ "event": "post.publish", "timestamp": 1750000000, "test": true, "post": { ... } }

What your endpoint should do: handle it exactly as a live delivery - verify the signature, parse it, run your usual validation - then reply 200 with the published_url you would have used, and discard it. Do not save it, publish it, put it in a feed or sitemap, or email your subscribers about it. A missing or false test means a real delivery.

Why a test post, when there's already a ping? A ping is ~90 bytes with no post in it: it proves your route exists and your signature check works, nothing more. The test carries every field a live delivery does, including a full-size hero image (~120 KB on the wire), so it catches firewall rules, body-size limits and a wrong published_url shape.

Test deliveries run the full path: a media.upload with a real image, then a post.publish. Both are flagged. They also show up in Recent deliveries under Settings → Publishing, marked test, alongside the exact response your endpoint sent back.

Contract versions

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

  • v1 (original): ping, media.upload, post.publish, post.update.
  • v2 adds post.delete - remove a post by slug when a member archives it; ping now returns version.
  • v3 adds the test flag, so Test connection can send a full-shaped post your endpoint acknowledges without publishing it.

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.

Upgrading v2 → v3: honour test - one early return before you save is usually the whole change - then report "version": 3 from ping. We only send a test post to endpoints that report v3, because below that you haven't promised to keep it off your live site and we'd have no way to take it back. On v1 and v2, Test connection stays a ping only.