Connectez votre propre site

Publiez survotre propre site.

Si votre site n’est pas sur WordPress, Webflow ou une autre plateforme prise en charge, créez un petit récepteur. AutoSeoPro.ai lui envoie des requêtes signées pour publier, modifier et supprimer des articles, et votre site répond par un reçu.

De quoi il s’agit

Un récepteur est une adresse web de votre site qui accepte les requêtes POST d’AutoSeoPro.ai. Chaque requête est signée avec un secret que seuls vous et AutoSeoPro.ai connaissez : votre site peut ainsi prouver qu’elle est authentique. Pour chaque article, quatre événements sont possibles : ping (un test), publish, update et delete.

AutoSeoPro.ai ne considère un article comme publié que si votre réponse est un reçu complet qui correspond à la requête. Un simple 200 ne prouve rien.

L’objet article peut contenir d’autres champs que ceux décrits ici, comme les catégories et les étiquettes. Ignorez ceux que vous n’utilisez pas.

Mise en place en cinq étapes

  1. Créez le récepteur. Partez d’un kit ci-dessous (Node.js, PHP ou Python) et remplacez les fonctions de traitement par des appels à votre CMS ou à votre base de données.
  2. Créez un secret. Générez au moins 32 caractères aléatoires, par exemple avec openssl rand -hex 32, et gardez-le dans l’environnement de votre récepteur, jamais dans du code que vous publiez.
  3. Ajoutez-le dans AutoSeoPro.ai. Ouvrez Connexions, choisissez Autre plateforme (API REST), puis saisissez l’URL du récepteur, l’adresse publique de votre site (l’origine https exacte, comme https://www.example.com) et le secret.
  4. Lancez le test de connexion. AutoSeoPro.ai envoie un ping signé et n’accepte qu’une réponse qui reprend la clé d’idempotence avec accepted: true.
  5. Publiez. Publiez un article à la main pour vérifier le résultat sur votre site, puis activez le pilote automatique.

Ce que contient chaque requête

Chaque requête est un POST avec un corps JSON et ces en-têtes.

En-têteSignification
Content-TypeToujours application/json.
AuthorizationBearer suivi de votre secret. Le même secret signe la requête.
X-Rankora-ProtocolToujours 2026-08-27. Refusez toute autre valeur.
X-Rankora-Eventping, publish, update ou delete. Doit être égal au champ event du corps.
X-Rankora-Idempotency-KeyUnique par opération, 128 caractères au plus. Sert à reconnaître les rejeux.
X-Rankora-TimestampHeure Unix, en secondes, de la signature de la requête.
X-Rankora-Signaturev1= suivi du HMAC-SHA256 de la requête en hexadécimal minuscule.

Comment la signature est calculée

Vérifiez la signature avant d’analyser le corps. Si une vérification échoue, répondez 401 et ne faites rien d’autre.

  1. Lisez le corps brut de la requête sous forme d’octets. Ne l’analysez pas puis ne le réencodez pas, car cela modifie les octets.
  2. Construisez la chaîne timestamp.idempotencyKey.rawBody : l’en-tête X-Rankora-Timestamp, l’en-tête X-Rankora-Idempotency-Key et le corps brut, séparés par des points.
  3. Calculez le HMAC-SHA256 de cette chaîne avec votre secret et écrivez-le en hexadécimal minuscule.
  4. Comparez v1= suivi de cet hexadécimal à l’en-tête X-Rankora-Signature en temps constant, par exemple avec crypto.timingSafeEqual, hash_equals ou hmac.compare_digest.
  5. Refusez un horodatage éloigné de plus de cinq minutes de votre horloge, dans un sens comme dans l’autre, ainsi qu’un jeton Authorization: Bearer absent ou erroné.
La signature en 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;

Idempotence

AutoSeoPro.ai réessaie après un délai dépassé : le même événement peut donc arriver plusieurs fois. Votre récepteur ne doit modifier votre site qu’une seule fois.

  • Chaque événement porte une clé d’idempotence. Conservez la clé avec la réponse exacte que vous avez envoyée.
  • Quand une clé revient, renvoyez la réponse conservée et ne touchez plus à votre CMS.
  • Comparez uniquement la clé. Un nouvel essai a un nouvel horodatage et un nouveau occurredAt : son corps diffère du premier essai.
  • Ne conservez la réponse qu’une fois la modification réussie. Si votre traitement échoue, répondez par un code d’erreur et ne conservez rien, pour que le nouvel essai puisse réussir.
  • Deux requêtes avec la même clé peuvent arriver au même instant. Prenez un verrou par clé pour qu’une seule exécute votre traitement.
  • Une mise à jour utilise une clé dérivée du contenu de l’article : un contenu modifié donne une nouvelle clé, un contenu identique est un rejeu.

Formes des réponses

Répondez 200 avec un corps JSON. Les exemples montrent le corps de chaque requête en abrégé et la réponse exacte qu’attend AutoSeoPro.ai.

  • version vaut 2026-08-27 et idempotencyKey est la clé de la requête.
  • state vaut published quand la visibilité de l’article est publish, et draft quand elle est draft. Ne publiez jamais un brouillon.
  • postId et receiptId sont des identifiants stables de 256 caractères au plus (lettres, chiffres et . _ : / -). Une mise à jour renvoie le postId reçu.
  • Supprimer un article déjà disparu reste un succès : répondez deleted: true.
  • Pour refuser un événement, répondez 4xx ou 5xx avec un corps application/problem+json. Ne répondez jamais 2xx pour un échec.

Ping ping

Corps de la requête
{
  "version": "2026-08-27",
  "event": "ping",
  "platform": "rest_api",
  "idempotencyKey": "ping:6f1c2d3e-4a5b-4c6d-8e7f-9a0b1c2d3e4f",
  "occurredAt": "2026-10-13T09:30:00.000Z"
}
Réponse · 200
{
  "version": "2026-08-27",
  "event": "ping",
  "idempotencyKey": "ping:6f1c2d3e-4a5b-4c6d-8e7f-9a0b1c2d3e4f",
  "accepted": true
}

Publication publish

Corps de la requête
{
  "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"
  }
}
Réponse · 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"
}

Mise à jour update

Corps de la requête
{
  "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"
  }
}
Réponse · 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"
}

Suppression delete

Corps de la requête
{
  "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"
}
Réponse · 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."
}

L’adresse de réponse doit être sur votre site

L’url d’une réponse publish ou update doit être en https et exactement sur l’adresse publique du site saisie dans AutoSeoPro.ai : même schéma, même hôte, même port. Une réponse avec une autre adresse est rejetée comme reçu incomplet : une erreur ou un récepteur détourné ne peut donc pas diriger AutoSeoPro.ai vers un autre site.

Kits de récepteur

Chaque kit est un petit récepteur complet : contrôle du jeton et de la signature, fenêtre de cinq minutes, stockage d’idempotence avec verrou et traitement de démonstration qui enregistre les articles dans des fichiers. Copiez les fichiers et remplacez les traitements par vos appels au CMS.

Node.js (Express)

Nécessite Node 18 ou plus récent et npm install express. Le fichier de vérification n’a aucune dépendance.

Fichier: 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 };
  });
}
Fichier: 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, sans framework

Sans framework ni paquet Composer. Fonctionne sur un hébergement PHP ordinaire.

Fichier: 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);
    }
}
Fichier: 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 ou plus récent avec Flask. Le fichier de vérification n’utilise que la bibliothèque standard.

Fichier: 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
Fichier: 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 et les balises img du champ html sont des liens https absolus. Vous pouvez les laisser comme liens ou les copier dans votre propre stockage. Si vous les copiez, ne téléchargez que des adresses https, refusez les adresses privées, fixez un délai et une taille maximale, et gardez le lien d’origine si un téléchargement échoue.

Avant la production

Le stockage par fichiers convient à un seul serveur. Avec plusieurs serveurs, conservez les clés d’idempotence dans une table de base de données avec une clé unique, et servez le récepteur uniquement en HTTPS.