Auto-publish API

Implement one HTTPS endpoint that accepts signed JSON POSTs. It handles four events: ping, media.upload, post.publish, and post.update. Spec below.

Fastest: build it with your AI agent

Don't want to hand-write the handler? Paste this prompt into Claude Code, Cursor, or any coding agent inside your project. The full contract is baked in, so it can scaffold the endpoint for your exact stack.

Set up an incoming webhook endpoint for RankGoat, a service that auto-publishes blog posts to my website. Build a single HTTP POST endpoint (suggested path: /api/rankgoat-publish) for my project. First, figure out my framework / CMS and hosting from the codebase (ask me if it's unclear), then write the handler and tell me exactly where to deploy it.

The endpoint MUST implement this contract:

1. One URL receives every event as a JSON POST. Read the secret from the env var RANKGOAT_SECRET (I'll paste the value, never hardcode it).

2. Verify the request BEFORE parsing the body:
   - expected = "sha256=" + hex(HMAC-SHA256(rawRequestBodyBytes, RANKGOAT_SECRET))
   - Compare it to the X-RankGoat-Signature header with a constant-time comparison. Mismatch => respond 401 and stop.
   - Reject with 401 if X-RankGoat-Timestamp is more than 5 minutes from now.
   - The HMAC must run over the EXACT raw request body bytes, so capture the raw body. Do not let the framework parse then re-serialize the JSON first.

3. Branch on the JSON field "event":
   - "ping": respond 200 {"ok": true}. (Connectivity test.)
   - "media.upload": body has media: [{filename, content_type, data_base64}]. Base64-decode each file, store it (disk, S3, or my CMS media library), and respond 200 {"media": [{"filename", "url"}]} with each file's public URL. Files I don't return are dropped.
   - "post.publish": body has post: {title, slug, meta_description, body_html, hero_image_url, json_ld, language, alternates, word_count} and outbound_links. body_html and hero_image_url already point at the URLs I returned from media.upload. Create and publish the post, then respond 200 {"published_url": "https://MYSITE/blog/THE-SLUG"} with the real live URL.
   - "post.update": same payload as post.publish. Find the existing post by post.slug and update it in place (do NOT create a duplicate or change its URL); if the slug is unknown, create it. Respond 200 {"published_url": ...} with the live URL.

4. When rendering the published post:
   - Inject json_ld into the page <head> verbatim as <script type="application/ld+json">...</script> (it's schema.org BlogPosting + FAQPage).
   - Make each post self-canonical (its canonical points at its own URL).
   - If alternates is non-empty (list of {lang, url}), emit reciprocal <link rel="alternate" hreflang="..."> tags plus an x-default. It's [] today.
   - Do NOT modify the embedded outbound links: keep every anchor text and href exactly as sent, and never add rel="nofollow". RankGoat checks these daily.

5. Operational:
   - Respond within 30 seconds. If a delivery fails RankGoat retries at +5 min and +1 hr, then emails me to fix the endpoint.
   - Make publishing idempotent on slug: a repeated slug should update the existing post, not create a duplicate.

When you're done, tell me: the endpoint URL to paste into RankGoat's publishing settings, that the secret goes in RANKGOAT_SECRET, and how to test it locally.

Endpoint spec

One URL, POST, Content-Type: application/json. Branch on the event field. Every request carries these headers:

X-RankGoat-Event:     ping | media.upload | post.publish | post.update
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. Return JSON; respond within 30s.

ping

Connectivity test. No body fields beyond event/timestamp.

// 200:
{ "ok": true }

media.upload

Sent before any post with images. Store each file, return its public URL. We rewrite the article to your URLs, then send post.publish. Images you don't return are dropped - we don't host them for you.

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

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

post.publish

body_html and hero_image_url already point at your media URLs. Create the post, return its URL. json_ld is schema.org structured data (BlogPosting + FAQPage) - inject it into the page <head> as <script type="application/ld+json"> for rich results. The visible FAQ is already in body_html, so the two match.

{
  "event": "post.publish",
  "timestamp": 1715900000,
  "post": {
    "id": 42,
    "title": "Five Ways to Speed Up Your Static Site",
    "slug": "five-ways-to-speed-up-your-static-site",
    "meta_description": "...",
    "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", "headline": "...", "inLanguage": "en" }, { "@type": "FAQPage", "mainEntity": [] } ] },
    "language": "en",
    "alternates": [],
    "word_count": 1180
  },
  "outbound_links": [
    { "target_domain": "techreview.io", "anchor": "modern build pipelines", "href": "https://techreview.io" }
  ]
}

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

post.update

Sent when a member edits an already-published post. Identical payload to post.publish; match on post.slug (stable, never changes) and update the existing post in place instead of creating a new one. Same media-rewrite rules apply. Respond 200 { "published_url": "..." } with the live URL (normally unchanged). If you don't recognise the slug, treat it as a create.

{
  "event": "post.update",
  "timestamp": 1715900000,
  "post": { "slug": "five-ways-to-speed-up-your-static-site", "title": "...", "body_html": "...", ... },
  "outbound_links": [ ... ]
}

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

Languages

language is the post's language (BCP-47, e.g. en). Sites are English-only today; multiple languages per site are coming. To stay correct now and forward-compatible:

Handler

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.RANKGOAT_SECRET;

app.post("/api/rankgoat-publish", async (req, res) => {
  const sigHeader = req.get("X-RankGoat-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 });

  // 1. Store each image, return { media: [{ filename, url }] }.
  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")), // your storage / CDN
    })));
    return res.json({ media });
  }

  // 2. Create or update the post - body_html already references your media URL.
  // post.update carries the same shape; upsert on slug so an edit updates in
  // place. Store json_ld and print it in the page <head> as
  // <script type="application/ld+json">${JSON.stringify(post.json_ld)}</script>.
  if (payload.event === "post.publish" || payload.event === "post.update") {
    const post = payload.post;
    await db.posts.upsert({ slug: post.slug }, {
      title: post.title,
      slug: post.slug,
      body_html: post.body_html,
      meta_description: post.meta_description,
      json_ld: JSON.stringify(post.json_ld),
      published_at: new Date(),
    });
    return res.json({ published_url: `https://yoursite.com/blog/${post.slug}` });
  }

  res.status(400).json({ error: "unknown event" });
});

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

function rankgoat_handle_publish(WP_REST_Request $req) {
  $secret = getenv("RANKGOAT_SECRET");
  $body   = $req->get_body();
  $sig    = $req->get_header("X-RankGoat-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];

  // 1. Store each image in the Media Library, return { media: [...] }.
  if ($payload["event"] === "media.upload") {
    $media = [];
    foreach ($payload["media"] as $m) {
      $up = wp_upload_bits($m["filename"], null, base64_decode($m["data_base64"]));
      $media[] = ["filename" => $m["filename"], "url" => $up["url"]];
    }
    return ["media" => $media];
  }

  // 2. Create the post - post_content already references your media URL.
  $post = $payload["post"];
  $post_id = wp_insert_post([
    "post_title"   => $post["title"],
    "post_name"    => $post["slug"],
    "post_content" => $post["body_html"],
    "post_excerpt" => $post["meta_description"],
    "post_status"  => "publish",
    "post_type"    => "post",
  ]);

  // Save JSON-LD and emit it in <head> via a wp_head hook that reads this meta.
  update_post_meta($post_id, "rankgoat_jsonld", wp_json_encode($post["json_ld"]));

  return ["published_url" => get_permalink($post_id)];
}
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)

    # 1. Store each image, return {"media": [...]}.
    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)

    # 2. Create the post - body_html already references your media URL.
    # Store post["json_ld"] and print it in the page  as
    # .
    post = payload["post"]
    your_cms.create_post(
        title=post["title"],
        slug=post["slug"],
        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