Connect your own site

Publish toyour own site.

If your site is not on WordPress, Webflow or another supported platform, build a small receiver. AutoSeoPro.ai sends it signed requests to publish, update and delete articles, and your site replies with a receipt.

What it is

A receiver is one web address on your site that accepts POST requests from AutoSeoPro.ai. Every request is signed with a secret only you and AutoSeoPro.ai know, so your site can prove it is genuine. For each article you get four possible events: ping (a test), publish, update and delete.

AutoSeoPro.ai counts an article as published only when your reply is a complete receipt that matches the request. A bare 200 is not proof.

The article object can carry fields beyond the ones documented here, such as categories and tags. Ignore the ones you do not use.

Set it up in five steps

  1. Build the receiver. Start from a kit below (Node.js, PHP or Python) and replace the handler functions with calls into your CMS or database.
  2. Make a secret. Generate at least 32 random characters, for example with openssl rand -hex 32, and keep it in your receiver's environment, never in code you publish.
  3. Add it in AutoSeoPro.ai. Open Connections, choose Other platform (REST API), then enter the receiver URL, your public site address (the exact https origin, such as https://www.example.com) and the secret.
  4. Run the connection test. AutoSeoPro.ai sends a signed ping and accepts only a reply that repeats the idempotency key with accepted: true.
  5. Publish. Publish one article by hand to check the result on your site, then switch on autopilot.

What every request carries

Every request is a POST with a JSON body and these headers.

HeaderMeaning
Content-TypeAlways application/json.
AuthorizationBearer plus your secret. The same secret signs the request.
X-Rankora-ProtocolAlways 2026-08-27. Reject any other value.
X-Rankora-Eventping, publish, update or delete. It must equal the event field in the body.
X-Rankora-Idempotency-KeyUnique per operation, up to 128 characters. Use it to recognise replays.
X-Rankora-TimestampUnix time in seconds when the request was signed.
X-Rankora-Signaturev1= followed by the lowercase hex HMAC-SHA256 of the request.

How the signature is computed

Check the signature before you parse the body. If any check fails, answer 401 and do nothing else.

  1. Read the raw request body as bytes. Do not parse and re-encode it first, because that changes the bytes.
  2. Build the string timestamp.idempotencyKey.rawBody: the X-Rankora-Timestamp header, the X-Rankora-Idempotency-Key header and the raw body, joined with dots.
  3. Compute HMAC-SHA256 of that string with your secret and write it as lowercase hex.
  4. Compare v1= plus that hex with the X-Rankora-Signature header in constant time, for example crypto.timingSafeEqual, hash_equals or hmac.compare_digest.
  5. Reject a timestamp more than five minutes away from your clock, in either direction, and a missing or wrong Authorization: Bearer token.
Signature in Node.js
import { createHmac, timingSafeEqual } from "node:crypto";

// rawBody is the Buffer you received, untouched.
const signed = `${timestamp}.${idempotencyKey}.`;
const expected = "v1=" + createHmac("sha256", secret)
  .update(signed)
  .update(rawBody)
  .digest("hex");

const ok = expected.length === signature.length &&
  timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) <= 300;

Idempotency

AutoSeoPro.ai retries after a timeout, so the same event can arrive more than once. Your receiver must change your site only once.

  • Every event carries an idempotency key. Store the key together with the exact reply you sent for it.
  • When a key comes back, return the stored reply and do not touch your CMS again.
  • Match on the key alone. A retry has a new timestamp and occurredAt, so its body differs from the first attempt.
  • Store the reply only after the change succeeded. If your handler fails, answer with an error status and store nothing, so the retry can still succeed.
  • Two requests with the same key can arrive at the same moment. Take a lock per key so that only one of them runs your handler.
  • An update uses a key derived from the article content: changed content is a new key, identical content is a replay.

Reply shapes

Answer 200 with a JSON body. The examples show each event's request body in brief and the exact reply AutoSeoPro.ai expects.

  • version is 2026-08-27 and idempotencyKey is the key from the request.
  • state is published when the article's visibility is publish and draft when it is draft. Never publish a draft.
  • postId and receiptId are stable ids of up to 256 characters (letters, digits and . _ : / -). An update returns the postId it was sent.
  • Deleting a post that is already gone is still a success: reply deleted: true.
  • To refuse an event, answer 4xx or 5xx with an application/problem+json body. Never answer 2xx for a failure.

Ping ping

Request body
{
  "version": "2026-08-27",
  "event": "ping",
  "platform": "rest_api",
  "idempotencyKey": "ping:6f1c2d3e-4a5b-4c6d-8e7f-9a0b1c2d3e4f",
  "occurredAt": "2026-10-13T09:30:00.000Z"
}
Reply · 200
{
  "version": "2026-08-27",
  "event": "ping",
  "idempotencyKey": "ping:6f1c2d3e-4a5b-4c6d-8e7f-9a0b1c2d3e4f",
  "accepted": true
}

Publish publish

Request body
{
  "version": "2026-08-27",
  "event": "publish",
  "platform": "rest_api",
  "idempotencyKey": "article-018f0d64-6e90-7d11-a123-0123456789ab",
  "occurredAt": "2026-10-13T09:30:00.000Z",
  "article": {
    "title": "How to repot a monstera",
    "html": "<p>Repot in spring, one size up…</p>",
    "slug": "how-to-repot-a-monstera",
    "metaTitle": "How to repot a monstera",
    "metaDescription": "A step-by-step guide to repotting a monstera without shocking the roots.",
    "focusKeyword": "repot monstera",
    "featuredImageUrl": "https://assets.example.net/monstera.jpg",
    "authorName": "Ana Silva",
    "publishMode": "publish",
    "idempotencyKey": "article-018f0d64-6e90-7d11-a123-0123456789ab",
    "visibility": "publish"
  }
}
Reply · 200
{
  "version": "2026-08-27",
  "event": "publish",
  "idempotencyKey": "article-018f0d64-6e90-7d11-a123-0123456789ab",
  "receiptId": "rcpt_9f3a1c7e2b4d6a80",
  "postId": "post_41",
  "state": "published",
  "url": "https://www.example.com/blog/how-to-repot-a-monstera"
}

Update update

Request body
{
  "version": "2026-08-27",
  "event": "update",
  "platform": "rest_api",
  "idempotencyKey": "update:3b9d5c1e3b9d5c1e3b9d5c1e3b9d5c1e3b9d5c1e3b9d5c1e3b9d5c1e3b9d5c1e",
  "occurredAt": "2026-10-13T09:30:00.000Z",
  "postId": "post_41",
  "article": {
    "title": "How to repot a monstera",
    "html": "<p>Repot in spring, one size up…</p>",
    "slug": "how-to-repot-a-monstera",
    "metaTitle": "How to repot a monstera",
    "metaDescription": "A step-by-step guide to repotting a monstera without shocking the roots.",
    "focusKeyword": "repot monstera",
    "featuredImageUrl": "https://assets.example.net/monstera.jpg",
    "authorName": "Ana Silva",
    "publishMode": "publish",
    "idempotencyKey": "article-018f0d64-6e90-7d11-a123-0123456789ab",
    "visibility": "publish"
  }
}
Reply · 200
{
  "version": "2026-08-27",
  "event": "update",
  "idempotencyKey": "update:3b9d5c1e3b9d5c1e3b9d5c1e3b9d5c1e3b9d5c1e3b9d5c1e3b9d5c1e3b9d5c1e",
  "receiptId": "rcpt_c41e7a9025bd3f16",
  "postId": "post_41",
  "state": "published",
  "url": "https://www.example.com/blog/how-to-repot-a-monstera"
}

Delete delete

Request body
{
  "version": "2026-08-27",
  "event": "delete",
  "platform": "rest_api",
  "idempotencyKey": "delete:8d2e5f10-7c3a-4b9e-a1d6-5e0f3c7b9a24",
  "occurredAt": "2026-10-13T09:30:00.000Z",
  "postId": "post_41"
}
Reply · 200
{
  "version": "2026-08-27",
  "event": "delete",
  "idempotencyKey": "delete:8d2e5f10-7c3a-4b9e-a1d6-5e0f3c7b9a24",
  "postId": "post_41",
  "deleted": true
}
401 · application/problem+json
{
  "type": "about:blank",
  "title": "Bad signature",
  "status": 401,
  "detail": "The signature does not match the body."
}

The reply address must be on your site

The url in a publish or update reply must be https and on exactly the public site address you entered in AutoSeoPro.ai: same scheme, host and port. A reply with any other address is rejected as an incomplete receipt, so a mistake or a hijacked receiver cannot point AutoSeoPro.ai at another site.

Receiver kits

Each kit is a small, complete receiver: bearer and signature check, the five-minute window, an idempotency store with a lock, and a demo handler that saves posts as files. Copy the files and replace the handlers with your CMS calls.

Node.js (Express)

Needs Node 18 or newer and npm install express. The verification file has no dependencies.

File: receiver-kits/node/autoseopro-receiver.mjs
/**
 * AutoSeoPro.ai signed publishing receiver: framework-free core (Node 18+, no dependencies).
 *
 * Protocol 2026-08-27. Copy this file next to server.mjs (the Express wiring).
 * It does five things, in this order, for every request:
 *
 *   1. checks the Bearer token (the same secret you pasted into AutoSeoPro.ai)
 *   2. checks the protocol version and the 5-minute timestamp window
 *   3. recomputes the HMAC-SHA256 signature over the RAW body bytes and compares it in constant time
 *   4. returns the stored reply when it has already seen this idempotency key (a replay never repeats the CMS change)
 *   5. otherwise runs your handler once, validates its answer, stores the reply and returns it
 *
 * Only step 5 touches your CMS. Everything else is the same for every site.
 */
import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { join } from "node:path";

export const PROTOCOL = "2026-08-27";
export const WINDOW_SECONDS = 300;
export const MIN_SECRET_LENGTH = 32;

const IDEMPOTENCY_KEY = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
const EXTERNAL_ID = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$/;
const TIMESTAMP = /^[0-9]{10,13}$/;
const SIGNATURE = /^v1=[a-f0-9]{64}$/;
const EVENTS = new Set(["ping", "publish", "update", "delete"]);

/** Compare two strings without leaking where they differ (hash first, so the lengths never matter). */
export function safeEqual(a, b) {
  const left = createHash("sha256").update(String(a)).digest();
  const right = createHash("sha256").update(String(b)).digest();
  return timingSafeEqual(left, right);
}

/** `v1=` + hex HMAC-SHA256 of `timestamp.idempotencyKey.rawBody`. The timestamp is the header text exactly as sent. */
export function sign(secret, timestamp, idempotencyKey, rawBody) {
  const mac = createHmac("sha256", secret);
  mac.update(`${timestamp}.${idempotencyKey}.`);
  mac.update(rawBody); // the exact bytes received, never JSON.stringify(JSON.parse(body))
  return `v1=${mac.digest("hex")}`;
}

function problem(status, title, detail) {
  return { status, contentType: "application/problem+json", body: { type: "about:blank", title, status, detail } };
}

function header(headers, name) {
  const wanted = name.toLowerCase();
  for (const [key, value] of Object.entries(headers ?? {})) {
    if (key.toLowerCase() === wanted) return Array.isArray(value) ? value[0] : value;
  }
  return undefined;
}

/**
 * Checks everything that proves the request is genuinely from AutoSeoPro.ai and is fresh.
 * Returns `{ ok: true, event, idempotencyKey }` or `{ ok: false, response }`.
 */
export function verifyRequest({ headers, rawBody }, { secret, now = () => Date.now() }) {
  if (typeof secret !== "string" || secret.length < MIN_SECRET_LENGTH) {
    return { ok: false, response: problem(500, "Receiver is not configured", "The shared secret must be at least 32 characters.") };
  }
  const authorization = header(headers, "authorization") ?? "";
  if (!authorization.startsWith("Bearer ") || !safeEqual(authorization.slice(7), secret)) {
    return { ok: false, response: problem(401, "Unauthorized", "Missing or wrong bearer token.") };
  }
  if (header(headers, "x-rankora-protocol") !== PROTOCOL) {
    return { ok: false, response: problem(400, "Unsupported protocol version", `Only ${PROTOCOL} is supported.`) };
  }
  const event = header(headers, "x-rankora-event") ?? "";
  const idempotencyKey = header(headers, "x-rankora-idempotency-key") ?? "";
  const timestamp = header(headers, "x-rankora-timestamp") ?? "";
  const signature = header(headers, "x-rankora-signature") ?? "";
  if (!EVENTS.has(event) || !IDEMPOTENCY_KEY.test(idempotencyKey) || !TIMESTAMP.test(timestamp) || !SIGNATURE.test(signature)) {
    return { ok: false, response: problem(400, "Malformed headers", "An X-Rankora-* header is missing or invalid.") };
  }
  // Ten digits are seconds (what AutoSeoPro.ai sends); thirteen are milliseconds.
  const seconds = timestamp.length >= 13 ? Math.floor(Number(timestamp) / 1000) : Number(timestamp);
  if (Math.abs(Math.floor(now() / 1000) - seconds) > WINDOW_SECONDS) {
    return { ok: false, response: problem(401, "Stale timestamp", "The request is outside the 5-minute window.") };
  }
  if (!safeEqual(signature, sign(secret, timestamp, idempotencyKey, rawBody))) {
    return { ok: false, response: problem(401, "Bad signature", "The signature does not match the body.") };
  }
  return { ok: true, event, idempotencyKey };
}

/** One JSON file per idempotency key, written atomically, plus an in-process lock so two simultaneous
 *  deliveries of the same key never both reach your handler. Behind several processes use a database
 *  table with a UNIQUE(idempotency_key) constraint instead. */
export class FileStore {
  constructor(directory) {
    this.directory = directory;
    this.chains = new Map();
    mkdirSync(directory, { recursive: true });
  }
  path(key) {
    return join(this.directory, `${createHash("sha256").update(key).digest("hex")}.json`);
  }
  get(key) {
    try {
      return JSON.parse(readFileSync(this.path(key), "utf8"));
    } catch {
      return undefined;
    }
  }
  put(key, reply) {
    const target = this.path(key);
    const temporary = `${target}.${randomBytes(6).toString("hex")}.tmp`;
    writeFileSync(temporary, JSON.stringify(reply));
    renameSync(temporary, target);
  }
  async withLock(key, work) {
    const previous = this.chains.get(key) ?? Promise.resolve();
    const run = previous.then(work, work);
    const tail = run.catch(() => undefined);
    this.chains.set(key, tail);
    try {
      return await run;
    } finally {
      if (this.chains.get(key) === tail) this.chains.delete(key);
    }
  }
}

/** True when `url` is https on the origin (scheme + host + port) of your public site, e.g. "https://www.example.com". */
function onPublicOrigin(url, publicSiteOrigin) {
  try {
    const candidate = new URL(url);
    return candidate.protocol === "https:" && candidate.origin === new URL(publicSiteOrigin).origin;
  } catch {
    return false;
  }
}

function parseBody(rawBody, event, idempotencyKey) {
  let payload;
  try {
    payload = JSON.parse(Buffer.from(rawBody).toString("utf8"));
  } catch {
    return { error: problem(400, "Invalid JSON", "The body is not valid JSON.") };
  }
  if (!payload || payload.version !== PROTOCOL || payload.event !== event || payload.idempotencyKey !== idempotencyKey) {
    return { error: problem(400, "Header and body disagree", "version, event and idempotencyKey must match the headers.") };
  }
  return { payload };
}

/**
 * Handle one delivery.
 *
 * handlers = {
 *   publish({ article, idempotencyKey })           -> { postId, receiptId, url }
 *   update({ postId, article, idempotencyKey })    -> { postId, receiptId, url }   (postId must be the one received)
 *   delete({ postId, idempotencyKey })             -> void                         (deleting a missing post is still a success)
 * }
 * A handler throws an Error with `.status` (404, 409, 422...) to refuse; any other throw is a 500.
 * `ping` needs no handler. The article may carry fields beyond the ones documented: ignore what you do not use.
 */
export async function handleEvent({ headers, rawBody }, { secret, publicSiteOrigin, store, handlers, now }) {
  const verified = verifyRequest({ headers, rawBody }, { secret, now });
  if (!verified.ok) return verified.response;
  const { event, idempotencyKey } = verified;

  return store.withLock(idempotencyKey, async () => {
    // Replays are answered from the store and never reach the CMS again. The body of a retry can differ
    // (occurredAt changes), so the key alone decides.
    const stored = store.get(idempotencyKey);
    if (stored) return { status: 200, contentType: "application/json", body: stored };

    const parsed = parseBody(rawBody, event, idempotencyKey);
    if (parsed.error) return parsed.error;
    const { payload } = parsed;

    let receipt;
    try {
      if (event === "ping") {
        receipt = { version: PROTOCOL, event, idempotencyKey, accepted: true };
      } else if (event === "delete") {
        if (!EXTERNAL_ID.test(String(payload.postId ?? ""))) return problem(422, "Invalid postId", "postId is required for delete.");
        await handlers.delete({ postId: payload.postId, idempotencyKey });
        receipt = { version: PROTOCOL, event, idempotencyKey, postId: payload.postId, deleted: true };
      } else {
        const article = payload.article;
        if (!article || typeof article.title !== "string" || typeof article.html !== "string" || !article.title || !article.html) {
          return problem(422, "Invalid article", "article.title and article.html are required.");
        }
        if (event === "update" && !EXTERNAL_ID.test(String(payload.postId ?? ""))) return problem(422, "Invalid postId", "postId is required for update.");
        const result = event === "publish"
          ? await handlers.publish({ article, idempotencyKey })
          : await handlers.update({ postId: payload.postId, article, idempotencyKey });
        if (!EXTERNAL_ID.test(String(result?.postId)) || !EXTERNAL_ID.test(String(result?.receiptId)) || (event === "update" && result.postId !== payload.postId)) {
          return problem(500, "Handler returned no durable ids", "postId and receiptId are required and must be stable.");
        }
        // The reply URL must be on the public-site origin you configured in AutoSeoPro.ai, or the reply is not trusted.
        if (!onPublicOrigin(result.url, publicSiteOrigin)) {
          return problem(500, "URL outside the public site", `url must be https on ${publicSiteOrigin}.`);
        }
        receipt = {
          version: PROTOCOL,
          event,
          idempotencyKey,
          receiptId: result.receiptId,
          postId: result.postId,
          // The state must be exactly what was asked for: "publish" -> "published", "draft" -> "draft".
          state: (article.visibility ?? article.publishMode) === "publish" ? "published" : "draft",
          url: result.url,
        };
      }
    } catch (error) {
      const status = Number.isInteger(error?.status) && error.status >= 400 && error.status < 600 ? error.status : 500;
      return problem(status, status === 500 ? "Receiver error" : "Refused", status === 500 ? "The receiver failed; the event was not recorded." : String(error.message));
    }
    store.put(idempotencyKey, receipt); // only successes are stored, so a failed event can be retried
    return { status: 200, contentType: "application/json", body: receipt };
  });
}
File: receiver-kits/node/server.mjs
/**
 * AutoSeoPro.ai publishing receiver: Express wiring + a demo handler that saves posts as JSON files.
 *
 *   npm install express
 *   AUTOSEOPRO_RECEIVER_SECRET="$(openssl rand -hex 32)" PUBLIC_SITE_ORIGIN=https://www.example.com node server.mjs
 *
 * Put it behind HTTPS (your reverse proxy or host) and paste its public URL, e.g.
 * https://www.example.com/autoseopro/events, into AutoSeoPro.ai.
 *
 * Replace the three functions in `handlers` with calls into your CMS or database.
 */
import { createHash, randomUUID } from "node:crypto";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import express from "express";
import { FileStore, handleEvent } from "./autoseopro-receiver.mjs";

const secret = process.env.AUTOSEOPRO_RECEIVER_SECRET ?? "";
const publicSiteOrigin = process.env.PUBLIC_SITE_ORIGIN ?? "";
const dataDirectory = process.env.RECEIVER_DATA_DIR ?? "./receiver-data";
if (secret.length < 32 || !publicSiteOrigin) {
  console.error("Set AUTOSEOPRO_RECEIVER_SECRET (32+ characters) and PUBLIC_SITE_ORIGIN (https://your-site).");
  process.exit(1);
}

const postsDirectory = join(dataDirectory, "posts");
mkdirSync(postsDirectory, { recursive: true });
const store = new FileStore(join(dataDirectory, "idempotency"));

const slugify = (value) => String(value ?? "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80);
const postFile = (postId) => join(postsDirectory, `${createHash("sha256").update(postId).digest("hex")}.json`);
const urlFor = (post) => `${publicSiteOrigin.replace(/\/$/, "")}/blog/${post.slug}`;
const receiptIdFor = (idempotencyKey) => `rcpt_${createHash("sha256").update(idempotencyKey).digest("hex").slice(0, 16)}`;

/**
 * Images: `article.featuredImageUrl` and any <img src> inside `article.html` are absolute https URLs.
 * Either hotlink them as they are, or copy them to your own storage first. If you copy: fetch only https URLs,
 * refuse private and loopback addresses, set a timeout, cap the size (for example 10 MB) and check the
 * Content-Type is image/*. Never let a failed image download fail the publish; keep the original URL instead.
 */
const handlers = {
  publish({ article, idempotencyKey }) {
    const postId = `post_${randomUUID()}`;
    const post = { postId, slug: slugify(article.slug || article.title) || postId, article, state: article.visibility };
    writeFileSync(postFile(postId), JSON.stringify(post));
    return { postId, receiptId: receiptIdFor(idempotencyKey), url: urlFor(post) };
  },
  update({ postId, article, idempotencyKey }) {
    let post;
    try {
      post = JSON.parse(readFileSync(postFile(postId), "utf8"));
    } catch {
      throw Object.assign(new Error("Unknown postId."), { status: 404 });
    }
    post = { ...post, article, state: article.visibility, slug: slugify(article.slug || article.title) || post.slug };
    writeFileSync(postFile(postId), JSON.stringify(post));
    return { postId, receiptId: receiptIdFor(idempotencyKey), url: urlFor(post) };
  },
  delete({ postId }) {
    // Deleting something that is already gone is still a success: the goal state is "not there".
    writeFileSync(postFile(postId), JSON.stringify({ postId, deleted: true }));
  },
};

const app = express();
// The signature covers the exact bytes, so keep the body raw. Do NOT use express.json() on this route.
app.post("/autoseopro/events", express.raw({ type: "*/*", limit: "5mb" }), async (req, res) => {
  const reply = await handleEvent(
    { headers: req.headers, rawBody: Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0) },
    { secret, publicSiteOrigin, store, handlers },
  );
  res.status(reply.status).type(reply.contentType).send(JSON.stringify(reply.body));
});

const port = Number(process.env.PORT ?? 3000);
app.listen(port, () => console.log(`Receiver listening on :${port}/autoseopro/events`));

PHP 8, no framework

No framework and no Composer packages. Works on ordinary PHP hosting.

File: receiver-kits/php/autoseopro_receiver.php
<?php
/**
 * AutoSeoPro.ai signed publishing receiver: the verification and idempotency core (PHP 8+, no framework,
 * no Composer packages). Protocol 2026-08-27.
 *
 * For every request it: checks the Bearer token, the protocol version and the 5-minute timestamp window,
 * recomputes the HMAC-SHA256 signature over the RAW body and compares it in constant time, answers a
 * replayed idempotency key from the store without touching your CMS, and otherwise runs your handler once
 * and stores the reply. Only the handlers in index.php talk to your CMS.
 */
declare(strict_types=1);

const AUTOSEOPRO_PROTOCOL = '2026-08-27';
const AUTOSEOPRO_WINDOW_SECONDS = 300;
const AUTOSEOPRO_MIN_SECRET_LENGTH = 32;

/** Compare two strings without leaking where they differ (hash first, so the lengths never matter). */
function autoseopro_safe_equal(string $a, string $b): bool
{
    return hash_equals(hash('sha256', $a, true), hash('sha256', $b, true));
}

/** `v1=` + hex HMAC-SHA256 of "timestamp.idempotencyKey.rawBody". The timestamp is the header text exactly as sent. */
function autoseopro_sign(string $secret, string $timestamp, string $idempotencyKey, string $rawBody): string
{
    return 'v1=' . hash_hmac('sha256', $timestamp . '.' . $idempotencyKey . '.' . $rawBody, $secret);
}

/** A problem+json answer: [status, content type, body]. */
function autoseopro_problem(int $status, string $title, string $detail): array
{
    return [$status, 'application/problem+json', ['type' => 'about:blank', 'title' => $title, 'status' => $status, 'detail' => $detail]];
}

/** Case-insensitive header lookup in an array of name => value. */
function autoseopro_header(array $headers, string $name): string
{
    foreach ($headers as $key => $value) {
        if (strcasecmp((string) $key, $name) === 0) {
            return is_array($value) ? (string) ($value[0] ?? '') : (string) $value;
        }
    }
    return '';
}

/** Request headers from $_SERVER. Some Apache/FastCGI setups hide Authorization; see the .htaccess note in index.php. */
function autoseopro_request_headers(): array
{
    $headers = [];
    foreach ($_SERVER as $key => $value) {
        if (strncmp($key, 'HTTP_', 5) === 0) {
            $headers[str_replace('_', '-', substr($key, 5))] = $value;
        }
    }
    if (!isset($headers['AUTHORIZATION']) && isset($_SERVER['REDIRECT_HTTP_AUTHORIZATION'])) {
        $headers['AUTHORIZATION'] = $_SERVER['REDIRECT_HTTP_AUTHORIZATION'];
    }
    return $headers;
}

/**
 * Proves the request is genuinely from AutoSeoPro.ai and is fresh.
 * Returns ['ok' => true, 'event' => ..., 'idempotencyKey' => ...] or ['ok' => false, 'response' => [status, type, body]].
 */
function autoseopro_verify(array $headers, string $rawBody, string $secret, ?int $now = null): array
{
    if (strlen($secret) < AUTOSEOPRO_MIN_SECRET_LENGTH) {
        return ['ok' => false, 'response' => autoseopro_problem(500, 'Receiver is not configured', 'The shared secret must be at least 32 characters.')];
    }
    $authorization = autoseopro_header($headers, 'authorization');
    if (strncmp($authorization, 'Bearer ', 7) !== 0 || !autoseopro_safe_equal(substr($authorization, 7), $secret)) {
        return ['ok' => false, 'response' => autoseopro_problem(401, 'Unauthorized', 'Missing or wrong bearer token.')];
    }
    if (autoseopro_header($headers, 'x-rankora-protocol') !== AUTOSEOPRO_PROTOCOL) {
        return ['ok' => false, 'response' => autoseopro_problem(400, 'Unsupported protocol version', 'Only ' . AUTOSEOPRO_PROTOCOL . ' is supported.')];
    }
    $event = autoseopro_header($headers, 'x-rankora-event');
    $key = autoseopro_header($headers, 'x-rankora-idempotency-key');
    $timestamp = autoseopro_header($headers, 'x-rankora-timestamp');
    $signature = autoseopro_header($headers, 'x-rankora-signature');
    if (!in_array($event, ['ping', 'publish', 'update', 'delete'], true)
        || preg_match('/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/', $key) !== 1
        || preg_match('/^[0-9]{10,13}$/', $timestamp) !== 1
        || preg_match('/^v1=[a-f0-9]{64}$/', $signature) !== 1) {
        return ['ok' => false, 'response' => autoseopro_problem(400, 'Malformed headers', 'An X-Rankora-* header is missing or invalid.')];
    }
    // Ten digits are seconds (what AutoSeoPro.ai sends); thirteen are milliseconds.
    $seconds = strlen($timestamp) >= 13 ? intdiv((int) $timestamp, 1000) : (int) $timestamp;
    if (abs(($now ?? time()) - $seconds) > AUTOSEOPRO_WINDOW_SECONDS) {
        return ['ok' => false, 'response' => autoseopro_problem(401, 'Stale timestamp', 'The request is outside the 5-minute window.')];
    }
    if (!autoseopro_safe_equal($signature, autoseopro_sign($secret, $timestamp, $key, $rawBody))) {
        return ['ok' => false, 'response' => autoseopro_problem(401, 'Bad signature', 'The signature does not match the body.')];
    }
    return ['ok' => true, 'event' => $event, 'idempotencyKey' => $key];
}

/**
 * One JSON file per idempotency key plus an exclusive file lock, so two simultaneous deliveries of the same
 * key never both reach your handler. With several servers use a database table with a UNIQUE key instead.
 */
final class AutoSeoProFileStore
{
    public function __construct(private string $directory)
    {
        if (!is_dir($directory) && !mkdir($directory, 0700, true) && !is_dir($directory)) {
            throw new RuntimeException('Cannot create ' . $directory);
        }
    }

    private function path(string $key, string $extension): string
    {
        return $this->directory . '/' . hash('sha256', $key) . '.' . $extension;
    }

    public function get(string $key): ?array
    {
        $file = $this->path($key, 'json');
        if (!is_file($file)) {
            return null;
        }
        $decoded = json_decode((string) file_get_contents($file), true);
        return is_array($decoded) ? $decoded : null;
    }

    public function put(string $key, array $reply): void
    {
        $file = $this->path($key, 'json');
        $temporary = $file . '.' . bin2hex(random_bytes(6)) . '.tmp';
        file_put_contents($temporary, json_encode($reply, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
        rename($temporary, $file);
    }

    public function withLock(string $key, callable $work): mixed
    {
        $lock = fopen($this->path($key, 'lock'), 'c');
        if ($lock === false || !flock($lock, LOCK_EX)) {
            throw new RuntimeException('Cannot lock the idempotency store.');
        }
        try {
            return $work();
        } finally {
            flock($lock, LOCK_UN);
            fclose($lock);
        }
    }
}

/** True when $url is https on the origin (scheme + host + port) of your public site. */
function autoseopro_on_public_origin(mixed $url, string $publicSiteOrigin): bool
{
    if (!is_string($url)) {
        return false;
    }
    $candidate = parse_url($url);
    $configured = parse_url($publicSiteOrigin);
    if (!is_array($candidate) || !is_array($configured) || !isset($candidate['host'], $configured['host'])) {
        return false;
    }
    $origin = static fn (array $parts): string => strtolower(($parts['scheme'] ?? '') . '://' . $parts['host'] . ':' . ($parts['port'] ?? (($parts['scheme'] ?? '') === 'https' ? 443 : 80)));
    return ($candidate['scheme'] ?? '') === 'https' && !isset($candidate['user']) && $origin($candidate) === $origin($configured);
}

function autoseopro_valid_external_id(mixed $value): bool
{
    return is_string($value) && preg_match('/^[A-Za-z0-9][A-Za-z0-9._:\/-]{0,255}$/', $value) === 1;
}

/**
 * Handle one delivery. Returns [status, content type, body array].
 *
 * $config   = ['secret' => ..., 'public_site_origin' => 'https://www.example.com', 'now' => optional unix seconds]
 * $handlers = [
 *   'publish' => fn(array $article, string $idempotencyKey): array => ['postId' => ..., 'receiptId' => ..., 'url' => ...],
 *   'update'  => fn(string $postId, array $article, string $idempotencyKey): array => same shape, same postId,
 *   'delete'  => fn(string $postId, string $idempotencyKey): void   (deleting a missing post is still a success),
 * ]
 * A handler refuses by throwing AutoSeoProRefusal(status, message); any other exception is a 500.
 * ping needs no handler. The article may carry fields beyond the documented ones: ignore what you do not use.
 */
function autoseopro_handle(array $headers, string $rawBody, array $config, AutoSeoProFileStore $store, array $handlers): array
{
    $verified = autoseopro_verify($headers, $rawBody, (string) $config['secret'], $config['now'] ?? null);
    if (!$verified['ok']) {
        return $verified['response'];
    }
    $event = $verified['event'];
    $key = $verified['idempotencyKey'];

    return $store->withLock($key, static function () use ($event, $key, $rawBody, $config, $store, $handlers): array {
        // Replays are answered from the store and never reach the CMS again. The body of a retry can differ
        // (occurredAt changes), so the key alone decides.
        $stored = $store->get($key);
        if ($stored !== null) {
            return [200, 'application/json', $stored];
        }
        $payload = json_decode($rawBody, true);
        if (!is_array($payload)) {
            return autoseopro_problem(400, 'Invalid JSON', 'The body is not valid JSON.');
        }
        if (($payload['version'] ?? null) !== AUTOSEOPRO_PROTOCOL || ($payload['event'] ?? null) !== $event || ($payload['idempotencyKey'] ?? null) !== $key) {
            return autoseopro_problem(400, 'Header and body disagree', 'version, event and idempotencyKey must match the headers.');
        }

        try {
            if ($event === 'ping') {
                $receipt = ['version' => AUTOSEOPRO_PROTOCOL, 'event' => 'ping', 'idempotencyKey' => $key, 'accepted' => true];
            } elseif ($event === 'delete') {
                if (!autoseopro_valid_external_id($payload['postId'] ?? null)) {
                    return autoseopro_problem(422, 'Invalid postId', 'postId is required for delete.');
                }
                ($handlers['delete'])($payload['postId'], $key);
                $receipt = ['version' => AUTOSEOPRO_PROTOCOL, 'event' => 'delete', 'idempotencyKey' => $key, 'postId' => $payload['postId'], 'deleted' => true];
            } else {
                $article = $payload['article'] ?? null;
                if (!is_array($article) || !is_string($article['title'] ?? null) || $article['title'] === '' || !is_string($article['html'] ?? null) || $article['html'] === '') {
                    return autoseopro_problem(422, 'Invalid article', 'article.title and article.html are required.');
                }
                if ($event === 'update' && !autoseopro_valid_external_id($payload['postId'] ?? null)) {
                    return autoseopro_problem(422, 'Invalid postId', 'postId is required for update.');
                }
                $result = $event === 'publish'
                    ? ($handlers['publish'])($article, $key)
                    : ($handlers['update'])($payload['postId'], $article, $key);
                if (!autoseopro_valid_external_id($result['postId'] ?? null) || !autoseopro_valid_external_id($result['receiptId'] ?? null)
                    || ($event === 'update' && $result['postId'] !== $payload['postId'])) {
                    return autoseopro_problem(500, 'Handler returned no durable ids', 'postId and receiptId are required and must be stable.');
                }
                // The reply URL must be on the public-site origin you configured in AutoSeoPro.ai, or the reply is not trusted.
                if (!autoseopro_on_public_origin($result['url'] ?? null, (string) $config['public_site_origin'])) {
                    return autoseopro_problem(500, 'URL outside the public site', 'url must be https on ' . $config['public_site_origin'] . '.');
                }
                $requested = $article['visibility'] ?? ($article['publishMode'] ?? 'draft');
                $receipt = [
                    'version' => AUTOSEOPRO_PROTOCOL,
                    'event' => $event,
                    'idempotencyKey' => $key,
                    'receiptId' => $result['receiptId'],
                    'postId' => $result['postId'],
                    // The state must be exactly what was asked for: "publish" -> "published", "draft" -> "draft".
                    'state' => $requested === 'publish' ? 'published' : 'draft',
                    'url' => $result['url'],
                ];
            }
        } catch (AutoSeoProRefusal $refusal) {
            return autoseopro_problem($refusal->status, 'Refused', $refusal->getMessage());
        } catch (Throwable $error) {
            return autoseopro_problem(500, 'Receiver error', 'The receiver failed; the event was not recorded.');
        }
        $store->put($key, $receipt); // only successes are stored, so a failed event can be retried
        return [200, 'application/json', $receipt];
    });
}

/** Throw from a handler to refuse an event with a 4xx status (404 unknown post, 409 conflict, 422 invalid content). */
final class AutoSeoProRefusal extends RuntimeException
{
    public function __construct(public int $status, string $message)
    {
        parent::__construct($message);
    }
}
File: receiver-kits/php/index.php
<?php
/**
 * AutoSeoPro.ai publishing receiver: the entry point + a demo handler that saves posts as JSON files.
 *
 * Put this folder on any PHP 8 host over HTTPS and paste the URL that reaches this file, e.g.
 * https://www.example.com/autoseopro/events, into AutoSeoPro.ai (route /autoseopro/events to index.php with your
 * web server or a tiny rewrite). Set two environment variables on the host:
 *
 *   AUTOSEOPRO_RECEIVER_SECRET   a random 32+ character secret, the same value you paste into AutoSeoPro.ai
 *                             (generate one with: openssl rand -hex 32)
 *   PUBLIC_SITE_ORIGIN        https://www.example.com
 *
 * Apache hides the Authorization header from PHP unless you add this to .htaccess:
 *   RewriteEngine On
 *   RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
 *
 * Replace the three functions in $handlers with calls into your CMS or database.
 */
declare(strict_types=1);

require __DIR__ . '/autoseopro_receiver.php';

$secret = (string) getenv('AUTOSEOPRO_RECEIVER_SECRET');
$publicSiteOrigin = (string) getenv('PUBLIC_SITE_ORIGIN');
$dataDirectory = getenv('RECEIVER_DATA_DIR') ?: __DIR__ . '/receiver-data'; // keep this OUTSIDE the web root in production

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    http_response_code(405);
    header('Allow: POST');
    exit;
}

$store = new AutoSeoProFileStore($dataDirectory . '/idempotency');
if (!is_dir($dataDirectory . '/posts')) {
    mkdir($dataDirectory . '/posts', 0700, true);
}
$postFile = static fn (string $postId): string => $dataDirectory . '/posts/' . hash('sha256', $postId) . '.json';
$slugify = static fn (string $value): string => trim(substr(preg_replace('/[^a-z0-9]+/', '-', strtolower($value)) ?? '', 0, 80), '-');
$urlFor = static fn (string $slug): string => rtrim($publicSiteOrigin, '/') . '/blog/' . $slug;
$receiptIdFor = static fn (string $key): string => 'rcpt_' . substr(hash('sha256', $key), 0, 16);

/*
 * Images: article.featuredImageUrl and any <img src> inside article.html are absolute https URLs.
 * Either hotlink them as they are, or copy them to your own storage first. If you copy: fetch only https URLs,
 * refuse private and loopback addresses, set a timeout, cap the size (for example 10 MB) and check the
 * Content-Type is image/*. Never let a failed image download fail the publish; keep the original URL instead.
 */
$handlers = [
    'publish' => static function (array $article, string $key) use ($postFile, $slugify, $urlFor, $receiptIdFor): array {
        $postId = 'post_' . bin2hex(random_bytes(16));
        $slug = $slugify((string) ($article['slug'] ?? '') ?: $article['title']) ?: $postId;
        file_put_contents($postFile($postId), json_encode(['postId' => $postId, 'slug' => $slug, 'article' => $article]));
        return ['postId' => $postId, 'receiptId' => $receiptIdFor($key), 'url' => $urlFor($slug)];
    },
    'update' => static function (string $postId, array $article, string $key) use ($postFile, $slugify, $urlFor, $receiptIdFor): array {
        if (!is_file($postFile($postId))) {
            throw new AutoSeoProRefusal(404, 'Unknown postId.');
        }
        $slug = $slugify((string) ($article['slug'] ?? '') ?: $article['title']) ?: $postId;
        file_put_contents($postFile($postId), json_encode(['postId' => $postId, 'slug' => $slug, 'article' => $article]));
        return ['postId' => $postId, 'receiptId' => $receiptIdFor($key), 'url' => $urlFor($slug)];
    },
    'delete' => static function (string $postId, string $key) use ($postFile): void {
        // Deleting something that is already gone is still a success: the goal state is "not there".
        if (is_file($postFile($postId))) {
            unlink($postFile($postId));
        }
    },
];

[$status, $contentType, $body] = autoseopro_handle(
    autoseopro_request_headers(),
    (string) file_get_contents('php://input'), // the RAW bytes: never re-encode the JSON before verifying
    ['secret' => $secret, 'public_site_origin' => $publicSiteOrigin],
    $store,
    $handlers,
);
http_response_code($status);
header('Content-Type: ' . $contentType);
echo json_encode($body, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);

Python (Flask)

Python 3.8 or newer with Flask. The verification file uses only the standard library.

File: receiver-kits/python/autoseopro_receiver.py
"""AutoSeoPro.ai signed publishing receiver: framework-free core (Python 3.8+, standard library only).

Protocol 2026-08-27. For every request it checks the Bearer token, the protocol version and the 5-minute
timestamp window, recomputes the HMAC-SHA256 signature over the RAW body and compares it in constant time,
answers a replayed idempotency key from the store without touching your CMS, and otherwise runs your handler
once and stores the reply. Only the handlers in app.py talk to your CMS.
"""
import hashlib
import hmac
import json
import os
import re
import threading
import time
from contextlib import contextmanager
from urllib.parse import urlsplit

try:  # POSIX file locks so several worker processes share one store; Windows falls back to the thread lock.
    import fcntl
except ImportError:  # pragma: no cover
    fcntl = None

PROTOCOL = "2026-08-27"
WINDOW_SECONDS = 300
MIN_SECRET_LENGTH = 32

_IDEMPOTENCY_KEY = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
_EXTERNAL_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$")
_TIMESTAMP = re.compile(r"^[0-9]{10,13}$")
_SIGNATURE = re.compile(r"^v1=[a-f0-9]{64}$")
_EVENTS = ("ping", "publish", "update", "delete")


class Refusal(Exception):
    """Raise from a handler to refuse an event with a 4xx status (404 unknown post, 409 conflict, 422 invalid)."""

    def __init__(self, status, message):
        super().__init__(message)
        self.status = status


def safe_equal(a, b):
    """Compare two strings without leaking where they differ (hash first, so the lengths never matter)."""
    return hmac.compare_digest(hashlib.sha256(a.encode()).digest(), hashlib.sha256(b.encode()).digest())


def sign(secret, timestamp, idempotency_key, raw_body):
    """`v1=` + hex HMAC-SHA256 of "timestamp.idempotencyKey.rawBody". The timestamp is the header text as sent."""
    message = timestamp.encode() + b"." + idempotency_key.encode() + b"." + raw_body
    return "v1=" + hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()


def problem(status, title, detail):
    return status, "application/problem+json", {"type": "about:blank", "title": title, "status": status, "detail": detail}


def _header(headers, name):
    wanted = name.lower()
    for key, value in headers.items():
        if key.lower() == wanted:
            return value
    return ""


def verify_request(headers, raw_body, secret, now=None):
    """Prove the request is genuinely from AutoSeoPro.ai and is fresh.

    `raw_body` must be the exact bytes received (Flask: request.get_data()), never re-serialised JSON.
    Returns (event, idempotency_key, None) when valid, or (None, None, (status, content_type, body)) when not.
    """
    if len(secret) < MIN_SECRET_LENGTH:
        return None, None, problem(500, "Receiver is not configured", "The shared secret must be at least 32 characters.")
    authorization = _header(headers, "authorization")
    if not authorization.startswith("Bearer ") or not safe_equal(authorization[7:], secret):
        return None, None, problem(401, "Unauthorized", "Missing or wrong bearer token.")
    if _header(headers, "x-rankora-protocol") != PROTOCOL:
        return None, None, problem(400, "Unsupported protocol version", "Only " + PROTOCOL + " is supported.")
    event = _header(headers, "x-rankora-event")
    key = _header(headers, "x-rankora-idempotency-key")
    timestamp = _header(headers, "x-rankora-timestamp")
    signature = _header(headers, "x-rankora-signature")
    if (event not in _EVENTS or not _IDEMPOTENCY_KEY.match(key) or not _TIMESTAMP.match(timestamp)
            or not _SIGNATURE.match(signature)):
        return None, None, problem(400, "Malformed headers", "An X-Rankora-* header is missing or invalid.")
    # Ten digits are seconds (what AutoSeoPro.ai sends); thirteen are milliseconds.
    seconds = int(timestamp) // 1000 if len(timestamp) >= 13 else int(timestamp)
    if abs(int(now if now is not None else time.time()) - seconds) > WINDOW_SECONDS:
        return None, None, problem(401, "Stale timestamp", "The request is outside the 5-minute window.")
    if not safe_equal(signature, sign(secret, timestamp, key, raw_body)):
        return None, None, problem(401, "Bad signature", "The signature does not match the body.")
    return event, key, None


class FileStore:
    """One JSON file per idempotency key plus an exclusive lock, so two simultaneous deliveries of the same
    key never both reach your handler. With several servers use a database table with a UNIQUE key instead."""

    def __init__(self, directory):
        self.directory = directory
        self._thread_lock = threading.Lock()
        os.makedirs(directory, mode=0o700, exist_ok=True)

    def _path(self, key, extension):
        return os.path.join(self.directory, hashlib.sha256(key.encode()).hexdigest() + "." + extension)

    def get(self, key):
        try:
            with open(self._path(key, "json"), encoding="utf-8") as handle:
                return json.load(handle)
        except (OSError, ValueError):
            return None

    def put(self, key, reply):
        target = self._path(key, "json")
        temporary = "%s.%s.tmp" % (target, os.urandom(6).hex())
        with open(temporary, "w", encoding="utf-8") as handle:
            json.dump(reply, handle)
        os.replace(temporary, target)

    @contextmanager
    def lock(self, key):
        # flock is per open file, so it excludes other threads and other worker processes for the same key
        # without blocking different keys. Without fcntl (Windows) one process-wide lock is used instead.
        handle = open(self._path(key, "lock"), "a")
        try:
            if fcntl is not None:
                fcntl.flock(handle, fcntl.LOCK_EX)
                yield
            else:  # pragma: no cover
                with self._thread_lock:
                    yield
        finally:
            handle.close()  # closing releases the flock


def _on_public_origin(url, public_site_origin):
    """True when `url` is https on the origin (scheme + host + port) of your public site."""
    try:
        candidate, configured = urlsplit(url), urlsplit(public_site_origin)
        return (candidate.scheme == "https" and not candidate.username and candidate.hostname is not None
                and configured.hostname is not None
                and (candidate.hostname, candidate.port or 443) == (configured.hostname, configured.port or 443))
    except (ValueError, TypeError, AttributeError):
        return False


def handle_event(headers, raw_body, config, store, handlers):
    """Handle one delivery. Returns (status, content_type, body dict).

    config   = {"secret": ..., "public_site_origin": "https://www.example.com", "now": optional unix seconds}
    handlers = {
      "publish": lambda article, idempotency_key: {"postId": ..., "receiptId": ..., "url": ...},
      "update":  lambda post_id, article, idempotency_key: same shape, same postId,
      "delete":  lambda post_id, idempotency_key: None   (deleting a missing post is still a success),
    }
    A handler refuses by raising Refusal(status, message); any other exception becomes a 500.
    ping needs no handler. The article may carry fields beyond the documented ones: ignore what you do not use.
    """
    event, key, failure = verify_request(headers, raw_body, config["secret"], config.get("now"))
    if failure:
        return failure

    with store.lock(key):
        # Replays are answered from the store and never reach the CMS again. The body of a retry can differ
        # (occurredAt changes), so the key alone decides.
        stored = store.get(key)
        if stored is not None:
            return 200, "application/json", stored
        try:
            payload = json.loads(raw_body.decode("utf-8"))
        except (ValueError, UnicodeDecodeError):
            return problem(400, "Invalid JSON", "The body is not valid JSON.")
        if (not isinstance(payload, dict) or payload.get("version") != PROTOCOL
                or payload.get("event") != event or payload.get("idempotencyKey") != key):
            return problem(400, "Header and body disagree", "version, event and idempotencyKey must match the headers.")

        try:
            if event == "ping":
                receipt = {"version": PROTOCOL, "event": "ping", "idempotencyKey": key, "accepted": True}
            elif event == "delete":
                post_id = payload.get("postId")
                if not isinstance(post_id, str) or not _EXTERNAL_ID.match(post_id):
                    return problem(422, "Invalid postId", "postId is required for delete.")
                handlers["delete"](post_id, key)
                receipt = {"version": PROTOCOL, "event": "delete", "idempotencyKey": key, "postId": post_id, "deleted": True}
            else:
                article = payload.get("article")
                if (not isinstance(article, dict) or not isinstance(article.get("title"), str) or not article["title"]
                        or not isinstance(article.get("html"), str) or not article["html"]):
                    return problem(422, "Invalid article", "article.title and article.html are required.")
                post_id = payload.get("postId")
                if event == "update" and (not isinstance(post_id, str) or not _EXTERNAL_ID.match(post_id)):
                    return problem(422, "Invalid postId", "postId is required for update.")
                result = (handlers["publish"](article, key) if event == "publish"
                          else handlers["update"](post_id, article, key))
                valid_ids = all(isinstance(result.get(name), str) and _EXTERNAL_ID.match(result[name]) for name in ("postId", "receiptId"))
                if not valid_ids or (event == "update" and result["postId"] != post_id):
                    return problem(500, "Handler returned no durable ids", "postId and receiptId are required and must be stable.")
                # The reply URL must be on the public-site origin you configured in AutoSeoPro.ai, or the reply is not trusted.
                if not _on_public_origin(result.get("url"), config["public_site_origin"]):
                    return problem(500, "URL outside the public site", "url must be https on " + config["public_site_origin"] + ".")
                requested = article.get("visibility") or article.get("publishMode") or "draft"
                receipt = {
                    "version": PROTOCOL,
                    "event": event,
                    "idempotencyKey": key,
                    "receiptId": result["receiptId"],
                    "postId": result["postId"],
                    # The state must be exactly what was asked for: "publish" -> "published", "draft" -> "draft".
                    "state": "published" if requested == "publish" else "draft",
                    "url": result["url"],
                }
        except Refusal as refusal:
            return problem(refusal.status, "Refused", str(refusal))
        except Exception:  # noqa: BLE001 - a receiver must answer, never crash the worker
            return problem(500, "Receiver error", "The receiver failed; the event was not recorded.")
        store.put(key, receipt)  # only successes are stored, so a failed event can be retried
        return 200, "application/json", receipt
File: receiver-kits/python/app.py
"""AutoSeoPro.ai publishing receiver: Flask wiring + a demo handler that saves posts as JSON files.

    pip install flask
    export AUTOSEOPRO_RECEIVER_SECRET="$(openssl rand -hex 32)"
    export PUBLIC_SITE_ORIGIN=https://www.example.com
    flask --app app run        (use gunicorn or uWSGI behind HTTPS in production)

Paste the public URL, e.g. https://www.example.com/autoseopro/events, into AutoSeoPro.ai.
Replace the three functions in HANDLERS with calls into your CMS or database.
"""
import hashlib
import json
import os
import re
import secrets

from flask import Flask, Response, request

from autoseopro_receiver import FileStore, Refusal, handle_event

SECRET = os.environ.get("AUTOSEOPRO_RECEIVER_SECRET", "")
PUBLIC_SITE_ORIGIN = os.environ.get("PUBLIC_SITE_ORIGIN", "")
DATA_DIR = os.environ.get("RECEIVER_DATA_DIR", "./receiver-data")  # keep this OUTSIDE the web root in production

os.makedirs(os.path.join(DATA_DIR, "posts"), mode=0o700, exist_ok=True)
STORE = FileStore(os.path.join(DATA_DIR, "idempotency"))


def _post_file(post_id):
    return os.path.join(DATA_DIR, "posts", hashlib.sha256(post_id.encode()).hexdigest() + ".json")


def _slug(article, fallback):
    text = re.sub(r"[^a-z0-9]+", "-", (article.get("slug") or article["title"]).lower()).strip("-")[:80]
    return text or fallback


def _url(slug):
    return PUBLIC_SITE_ORIGIN.rstrip("/") + "/blog/" + slug


def _receipt_id(key):
    return "rcpt_" + hashlib.sha256(key.encode()).hexdigest()[:16]


def _save(post_id, article):
    slug = _slug(article, post_id)
    with open(_post_file(post_id), "w", encoding="utf-8") as handle:
        json.dump({"postId": post_id, "slug": slug, "article": article}, handle)
    return slug


# Images: article["featuredImageUrl"] and any <img src> inside article["html"] are absolute https URLs.
# Either hotlink them as they are, or copy them to your own storage first. If you copy: fetch only https URLs,
# refuse private and loopback addresses, set a timeout, cap the size (for example 10 MB) and check the
# Content-Type is image/*. Never let a failed image download fail the publish; keep the original URL instead.
def publish(article, key):
    post_id = "post_" + secrets.token_hex(16)
    return {"postId": post_id, "receiptId": _receipt_id(key), "url": _url(_save(post_id, article))}


def update(post_id, article, key):
    if not os.path.isfile(_post_file(post_id)):
        raise Refusal(404, "Unknown postId.")
    return {"postId": post_id, "receiptId": _receipt_id(key), "url": _url(_save(post_id, article))}


def delete(post_id, key):
    # Deleting something that is already gone is still a success: the goal state is "not there".
    try:
        os.remove(_post_file(post_id))
    except FileNotFoundError:
        pass


HANDLERS = {"publish": publish, "update": update, "delete": delete}

app = Flask(__name__)


@app.post("/autoseopro/events")
def autoseopro_events():
    # get_data() returns the exact bytes received. Never use request.get_json() before verifying the signature.
    status, content_type, body = handle_event(
        dict(request.headers),
        request.get_data(),
        {"secret": SECRET, "public_site_origin": PUBLIC_SITE_ORIGIN},
        STORE,
        HANDLERS,
    )
    return Response(json.dumps(body), status=status, content_type=content_type)

Images

featuredImageUrl and the img tags inside html are absolute https links. You can leave them as links, or copy them to your own storage. If you copy, fetch only https addresses, refuse private addresses, set a timeout and a size limit, and keep the original link when a download fails.

Before production

The file store suits one server. On several servers, keep idempotency keys in a database table with a unique key, and serve the receiver over HTTPS only.