Publish API v1
For developers of custom-built sites. Add one small signed endpoint to the site and RankAutomatic can publish blog articles to it, read them back and remove them.
How it works
You add one endpoint to your site, for example https://example.com/api/rankautomatic. RankAutomatic sends signed requests to it. You save the article in your own database and render it with your own templates. RankAutomatic then checks the live page against the rendering standard below and raises a plain task for each problem it finds.
Two downloads go with this page: openapi.yaml (the same contract, machine-readable, OpenAPI 3.1) and a Next.js starter: a ready App Router route handler with the signature check, where you fill in three functions that save, read and delete a post.
Connect
- In RankAutomatic, open Settings › Connections › Website and choose Custom site. Paste your endpoint URL. RankAutomatic shows a secret once; copy it.
- Put the secret in your site's environment as
RANKAUTOMATIC_SECRETand deploy. - Click Check connection. The connection becomes active only when all five conformance steps pass (see Troubleshooting).
To replace the secret, use Rotate secret in RankAutomatic, then update RANKAUTOMATIC_SECRET.
The contract
{base} is your endpoint URL without a trailing slash. Bodies are UTF-8 JSON.
| Method and path | What it does | Success |
|---|---|---|
| GET {base}/health | Reports the API version. | 200 {"ok":true,"version":"1","capabilities":["articles"]} |
| PUT {base}/articles/{external_id} | Creates or updates an article. | 200 {"external_id","url","status"} |
| GET {base}/articles/{external_id} | Reads back what the site stored. | 200 {"external_id","url","status","title","slug","updated_at","meta_description"} (meta_description optional) |
| DELETE {base}/articles/{external_id} | Removes the article. | 204 with no body |
GET and DELETE on a missing article answer 404 with {"error":"not_found","message":"..."}.
Read-back: updated_at and meta_description
updated_at must be an ISO 8601 date-time with a timezone offset (for example 2026-11-02T18:56:18Z), and it must change only when the article itself changes (title, body, meta description or status), not on page views, cache rebuilds or unrelated edits. RankAutomatic uses it to tell whether anyone edited the post on your site since our last push. When it can't tell, automatic updates (such as the automatic year refresh) fall back to a manual "Update on Website" button.
meta_description is optional: a string, or null (or omitted) when your site doesn't store one. Returning it lets those checks cover posts published before it was supported. Adding it keeps the API at version 1.
External ids
RankAutomatic sends external_id as ra-article-{id} (for example ra-article-42). While checking the connection it also uses ra-connection-test (status draft, title “RankAutomatic connection test”, slug rankautomatic-connection-test). Store the id with the post and use it as the key.
PUT body
Required: external_id, title, slug, body_html, status. Every other field is optional; treat a missing one as empty or null.
| Field | Type | Notes |
|---|---|---|
| external_id(required) | string | Equals the path id. |
| title(required) | string | |
| slug(required) | string | |
| body_html(required) | string | The article body as HTML. It has no H1: the title is the page's H1. |
| body_md | string | The same body as Markdown (no leading H1). |
| meta_description | string | |
| quick_answer | string or null | A 40-60 word answer-first summary of the article, plain sentences. Null for older articles. |
| faqs | object[] | [{ "question": "...", "answer": "..." }]; [] when the article has none. List "faqs" in the health capabilities when your page renders them, and the body leaves its FAQ section out. |
| focus_keyword | string | |
| featured_image_url | string or null | |
| featured_image_alt | string or null | |
| categories | string[] | The article's topic as one entry, or [] when it has none. |
| tags | string[] | Up to 5, each at most 40 characters. |
| schema_json | string | A JSON-LD document as text. |
| author | object or null | { "name": "...", "bio": "..." }; bio is optional. |
| published_at | string or null | ISO 8601. |
| status(required) | "draft" or "publish" | See drafts below. |
The response status is "draft" or "published", and url is the post's absolute public URL (also for a draft).
Errors
Errors are JSON { "error": "<code>", "message": "<plain sentence>" }. RankAutomatic shows your message to the person publishing, so keep it to one plain sentence.
| error | HTTP | Meaning |
|---|---|---|
| unauthorized | 401 | Missing, stale or wrong signature. |
| invalid | 400 | The body is not valid, or a field your site can't accept. |
| conflict | 409 | The article can't be saved as sent, for example the slug belongs to another post. |
| not_found | 404 | No article with that id. |
| error | 500 | Anything else. |
Signing
Every request is signed with the secret. Check the signature before you parse or act on the body, on every endpoint, including GET /health.
signature = hex( HMAC_SHA256( secret, "{timestamp}.{METHOD}.{path}.{raw_body}" ) )The method and path are part of the signature, so a signed request can't be replayed to another method or another article.
METHODis the HTTP method in uppercase (GET,PUT,DELETE).pathis the request path exactly as sent, without scheme, host or query string, for example/api/rankautomatic/articles/ra-article-12. It includes your endpoint's own path, and any percent-encoding stays as sent. In most frameworks it is the request URL's pathname (new URL(request.url).pathnamein JavaScript). If a proxy in front of your site rewrites the path, verify against the path RankAutomatic sent.raw_bodyis the exact text received, before any JSON parsing. It is the empty string forGETandDELETE. Re-serialising parsed JSON changes the bytes and breaks the check, so read the body as text once, verify, then parse.
Headers on each request:
X-RankAutomatic-Timestamp: Unix seconds.X-RankAutomatic-Signature: v1=<hex>X-RankAutomatic-Request-Id: A UUID, for your logs. It is not signed.
Reject the request with 401 when the signature is missing or wrong, or when the timestamp is more than 300 seconds from your server's clock (the replay window). Compare in constant time.
Test vector
- secret:
s - timestamp:
1700000000 - method:
GET - path:
/api/rankautomatic/health - body: empty
- signed text:
1700000000.GET./api/rankautomatic/health. - signature header:
v1=c0d3ffa65bd9db7c9cedf1265fab66d9175d635f84100ec45d3baf7087addfba
TypeScript
A minimal check. The starter's lib/rankautomatic/signature.ts is the full version, with missing-header and malformed-timestamp handling.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(
secret: string, timestamp: string, method: string, path: string, rawBody: string, header: string, nowSeconds: number,
) {
if (Math.abs(nowSeconds - Number(timestamp)) > 300) return "stale";
const signed = `${timestamp}.${method.toUpperCase()}.${path}.${rawBody}`;
const expected = Buffer.from("v1=" + createHmac("sha256", secret).update(signed).digest("hex"));
const given = Buffer.from(header);
return given.length === expected.length && timingSafeEqual(given, expected) ? "ok" : "bad_signature";
}Python
import hashlib, hmac, time
def verify(secret: str, timestamp: str | None, method: str, path: str, raw_body: bytes, header: str | None) -> str:
if not secret or not timestamp or not header:
return "missing"
if not timestamp.isdigit():
return "bad_signature"
if abs(time.time() - int(timestamp)) > 300:
return "stale"
signed = f"{timestamp}.{method.upper()}.{path}.".encode() + raw_body
digest = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
expected = ("v1=" + digest).encode()
return "ok" if hmac.compare_digest(header.encode(), expected) else "bad_signature"Idempotency and drafts
PUTwith anexternal_idyou already have updates that post. It never creates a second one. RankAutomatic may send the same article again at any time, for example after an edit.status: "draft"must never be publicly visible: not on the blog index, not at its URL (answer the public with404), not in the sitemap, not in a feed. Onlystatus: "publish"makes a post public. If a draft is later sent aspublish, the same post becomes public; if a published post is sent again asdraft, it must go back to hidden.- The connection check saves a draft and then deletes it. Your site must not show it in between.
DELETEremoves the post completely, so its URL answers404or410afterwards.
The rendering standard
After a post is published, RankAutomatic fetches its live URL and checks ten things. Each failure becomes one task for your site, with the fix named below.
| Check | The page must have | How to satisfy it |
|---|---|---|
| Live | An HTTP 200 at the url you returned. | Make the post public when status is publish, and return its real URL. |
| Indexable | No noindex robots meta tag and no X-Robots-Tag: noindex header. | Don't add noindex to blog posts. Check your framework's robots settings and any proxy or CDN header rules. |
| Title | A non-empty <title>. | Render the title field in the page <title> (add your site name if you like). |
| Meta description | <meta name="description" content="...">. | Render the meta_description field. |
| Canonical | <link rel="canonical"> pointing to the post's own URL. | Set the canonical to the same absolute URL you return as url. Don't point it at the blog index or another page. |
| One H1 | Exactly one <h1>. | Render the title as the single H1. body_html has no H1 of its own (RankAutomatic sends H2 and below); make sure your layout doesn't add another. |
| Structured data | A JSON-LD BlogPosting or Article block. | Render the schema_json field, unchanged, inside <script type="application/ld+json">. |
| Image alt text | A featured image with non-empty alt text near the top of the article. | Render featured_image_url as an <img> with alt set to featured_image_alt. |
| Author byline | The author's name visible in the page text. | Render author.name (and author.bio if you show one) in a byline. |
| SitemapA warning, not a failure | The post's URL listed in /sitemap.xml (or a sitemap index it points to). | Add published posts to your sitemap. Never list drafts. |
Versioning
This is v1. GET /health reports version; RankAutomatic refuses an endpoint whose major version it doesn't know. Fields can be added to v1 requests without notice, so ignore fields you don't recognise. A breaking change becomes v2 and is announced before it ships.
Troubleshooting by conformance step
Check connection runs five steps in order. The connection becomes active only when all pass; otherwise it shows “Not connected yet” and the first failing step.
- Health. “Your endpoint didn't answer the health check at {url}/health.” RankAutomatic called GET {base}/health with a valid signature and didn't get 200 {"ok":true,"version":"1",...}. Check the URL, that the route is deployed and public, and that it isn't behind a login or firewall. A 401 here means the secret differs: copy it again into RANKAUTOMATIC_SECRET and redeploy.
- Save a test draft. “Saving a test draft failed: {reason}.” The PUT for ra-connection-test didn't return 200 with url and status. The reason includes your endpoint's message. Common causes: the body was parsed and re-serialised before the signature check, a database error, or a required column the payload doesn't fill.
- Read back. “The draft read back with different details.” GET {base}/articles/ra-connection-test must return the same title ("RankAutomatic connection test") and slug (rankautomatic-connection-test), with status "draft". Store and return them as sent.
- Delete. “Removing the test draft failed.” DELETE must return 204. Make sure deleting by external_id works and that a second delete returns 404.
- Signature. “Your endpoint accepted a request that wasn't signed by RankAutomatic.” RankAutomatic sent GET {base}/health carrying a signature made for another method and path, and your endpoint answered 200. It must answer 401. Verify the signature before anything else, on every method, include the method and path in the signed text, and don't skip the check in preview builds that run on the live URL.
After connecting, if tasks appear about your blog (for example “Your blog isn't showing the article's structured data”), work through the rendering standard above, then use Re-check on the task.