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" }
body_html/hero_image_urlalready point at the URLs you returned frommedia.upload. Render as-is.json_ld: schema.org (BlogPosting + FAQPage). Print verbatim in<head>inside<script type="application/ld+json">. The visible FAQ is already inbody_html, so the two match.outbound_links: keep each anchor + href exactly. Never addnofollow.language/alternates: see Languages below. Respond with the livepublished_url.
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:
- Each page is self-canonical - point its canonical at its own URL, never at another language version.
- When a post has translations,
alternateslists them as[{ "lang", "url" }]. Emit reciprocal<link rel="alternate" hreflang="...">tags for this page plus every alternate (and anx-default). It's[]until translations exist.
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
- Verify
X-RankGoat-Signatureagainst the raw body bytes (constant-time). Reject ifX-RankGoat-Timestampis over 5 minutes old. Verify before parsing. - If a delivery fails (any non-2xx or timeout) we retry at +5 min and +1 hr. If it still fails we email you so you can fix the endpoint. Respond within 30s per request.
- Don't change embedded links (anchor, href, or add
nofollow). Flagged by our daily monitor.