← All docs

Auto-publish via API

Set up a webhook on your site once and every future RankGoat post arrives there automatically. No copy-paste, no submitting URLs back. We POST the post, your endpoint publishes it, returns the URL, we verify the links - done.

What you'll set up

  1. Build a tiny HTTP endpoint on your site (Node, PHP, Python, Ghost - examples below). It receives our POST, creates a blog post in your CMS, returns the published URL.
  2. In your RankGoat settings, paste the endpoint URL and click Generate to mint a shared secret. Copy the secret immediately - it's only shown once.
  3. Set the secret as an env var in your endpoint (we suggest BB_WEBHOOK_SECRET) so it can verify the HMAC signature we send.
  4. Click Test connectivity in settings. We send a non-publishing ping event. Your endpoint should respond 200.
  5. Flip Auto-publish enabled on. Done.

The webhook contract

Request

POST <your endpoint URL>
Content-Type: application/json
X-BB-Event: post.publish
X-BB-Timestamp: 1715900000
X-BB-Signature: sha256=<hex hmac of the raw request body, using your shared secret>
User-Agent: RankGoat/1.0 (+https://rankgoat.app)

{
  "event": "post.publish",
  "timestamp": 1715900000,
  "post": {
    "id": 42,
    "site_id": 7,
    "title": "Five Ways to Speed Up Your Static Site",
    "slug": "five-ways-to-speed-up-your-static-site",
    "meta_description": "Performance tricks that take a Hugo or Next.js site from B to A in Lighthouse.",
    "body_html": "<p>...</p>",
    "hero_image_url": "https://<our-host>/post-images/post-42.png",
    "word_count": 1180
  },
  "outbound_links": [
    { "target_domain": "techreview.io", "anchor": "modern build pipelines", "href": "https://techreview.io" },
    { "target_domain": "saashub.dev", "anchor": "SaaS boilerplate kits", "href": "https://saashub.dev" }
  ]
}

Expected response

HTTP/1.1 200 OK
Content-Type: application/json

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

Signature verification (do this server-side)

Compute the HMAC of the raw request body bytes using your shared secret. Compare with the value in X-BB-Signature after the sha256= prefix. Use a constant-time compare to avoid timing attacks. Reject if the signature doesn't match, or if X-BB-Timestamp is more than 5 minutes off.

Retry behaviour

Example endpoint handlers

Node.js / Express

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

const app = express();
// IMPORTANT: capture the raw body so HMAC matches the bytes we signed.
app.use(express.raw({ type: "application/json" }));

const SECRET = process.env.BB_WEBHOOK_SECRET;

app.post("/api/bb-publish", async (req, res) => {
  const sigHeader = req.get("X-BB-Signature") || "";
  const expected = "sha256=" + crypto.createHmac("sha256", SECRET).update(req.body).digest("hex");
  if (!crypto.timingSafeEqual(Buffer.from(sigHeader), 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 });

  // Create the post in your CMS however you like.
  const slug = payload.post.slug;
  await db.posts.insert({
    title: payload.post.title,
    slug,
    body_html: payload.post.body_html,
    meta_description: payload.post.meta_description,
    published_at: new Date(),
  });

  res.json({ published_url: `https://yoursite.com/blog/${slug}` });
});

app.listen(8080);

PHP / WordPress (drop into a small mu-plugin)

<?php
// mu-plugins/bb-publish.php - handles incoming RankGoat webhooks
add_action("rest_api_init", function () {
  register_rest_route("bb/v1", "/publish", [
    "methods"  => "POST",
    "callback" => "bb_handle_publish",
    "permission_callback" => "__return_true",
  ]);
});

function bb_handle_publish(WP_REST_Request $req) {
  $secret = getenv("BB_WEBHOOK_SECRET");
  $body   = $req->get_body();
  $sig    = $req->get_header("X-BB-Signature") ?: "";
  $expected = "sha256=" . hash_hmac("sha256", $body, $secret);
  if (!hash_equals($expected, $sig)) {
    return new WP_REST_Response(["error" => "bad signature"], 401);
  }

  $payload = json_decode($body, true);
  if ($payload["event"] === "ping") return ["ok" => true];

  $post_id = wp_insert_post([
    "post_title"   => $payload["post"]["title"],
    "post_name"    => $payload["post"]["slug"],
    "post_content" => $payload["post"]["body_html"],
    "post_excerpt" => $payload["post"]["meta_description"],
    "post_status"  => "publish",
    "post_type"    => "post",
  ]);

  return ["published_url" => get_permalink($post_id)];
}

Python / Flask

import hmac, hashlib, os, json
from flask import Flask, request, jsonify

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

@app.post("/api/bb-publish")
def bb_publish():
    body = request.get_data()
    sig  = request.headers.get("X-BB-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)

    slug = payload["post"]["slug"]
    your_cms.create_post(
        title=payload["post"]["title"],
        slug=slug,
        body=payload["post"]["body_html"],
        meta=payload["post"]["meta_description"],
    )
    return jsonify(published_url=f"https://yoursite.com/blog/{slug}")

Ghost

Ghost ships with native webhook support but only as an outgoing notifier, not an HTTP-callable post creator. For Ghost you have two routes:

  1. Use a small intermediate handler (the Node example above) that receives our webhook and then POSTs to Ghost's Admin API to create the post. Roughly 30 lines of code.
  2. Stick with manual paste via the Ghost HTML card guide.
Common pitfalls: