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

  1. In RankAutomatic, open Settings › Connections › Website and choose Custom site. Paste your endpoint URL. RankAutomatic shows a secret once; copy it.
  2. Put the secret in your site's environment as RANKAUTOMATIC_SECRET and deploy.
  3. 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 pathWhat it doesSuccess
GET {base}/healthReports 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.

FieldTypeNotes
external_id(required)stringEquals the path id.
title(required)string
slug(required)string
body_html(required)stringThe article body as HTML. It has no H1: the title is the page's H1.
body_mdstringThe same body as Markdown (no leading H1).
meta_descriptionstring
quick_answerstring or nullA 40-60 word answer-first summary of the article, plain sentences. Null for older articles.
faqsobject[][{ "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_keywordstring
featured_image_urlstring or null
featured_image_altstring or null
categoriesstring[]The article's topic as one entry, or [] when it has none.
tagsstring[]Up to 5, each at most 40 characters.
schema_jsonstringA JSON-LD document as text.
authorobject or null{ "name": "...", "bio": "..." }; bio is optional.
published_atstring or nullISO 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.

errorHTTPMeaning
unauthorized401Missing, stale or wrong signature.
invalid400The body is not valid, or a field your site can't accept.
conflict409The article can't be saved as sent, for example the slug belongs to another post.
not_found404No article with that id.
error500Anything 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.

  • METHOD is the HTTP method in uppercase (GET, PUT, DELETE).
  • path is 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).pathname in JavaScript). If a proxy in front of your site rewrites the path, verify against the path RankAutomatic sent.
  • raw_body is the exact text received, before any JSON parsing. It is the empty string for GET and DELETE. 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

  • PUT with an external_id you 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 with 404), not in the sitemap, not in a feed. Only status: "publish" makes a post public. If a draft is later sent as publish, the same post becomes public; if a published post is sent again as draft, it must go back to hidden.
  • The connection check saves a draft and then deletes it. Your site must not show it in between.
  • DELETE removes the post completely, so its URL answers 404 or 410 afterwards.

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.

CheckThe page must haveHow to satisfy it
LiveAn HTTP 200 at the url you returned.Make the post public when status is publish, and return its real URL.
IndexableNo 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.
TitleA 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 H1Exactly 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 dataA JSON-LD BlogPosting or Article block.Render the schema_json field, unchanged, inside <script type="application/ld+json">.
Image alt textA 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 bylineThe 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 failureThe 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.

  1. 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.
  2. 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.
  3. 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.
  4. Delete. “Removing the test draft failed.” DELETE must return 204. Make sure deleting by external_id works and that a second delete returns 404.
  5. 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.