Ir para o conteúdo
Documentação

Webhook de entrega: receber artigos no seu próprio site

O webhook assinado que envia os artigos do AutoPSEO para o seu site: cabeçalhos, cada evento e payload, as respostas a dar, novas tentativas, comandos em espera e IndexNow.

Propriedade → Conteúdo → Onde os artigos são publicados21 min de leitura

O AutoPSEO envia cada artigo que publica, atualiza ou retira para o seu próprio site como um POST HTTPS assinado. O seu site guarda o artigo e renderiza-o com os seus próprios templates, pelo que o conteúdo fica no seu sistema e continua a funcionar caso um dia deixe de usar o serviço. Esta página é a referência para o programador que constrói o recetor: assinatura, cada evento com o respetivo payload, as respostas que o AutoPSEO lê, novas tentativas, comandos em espera e IndexNow.

Configurar o webhook

  1. Abra a página Content da propriedade e vá a Where articles are published.
  2. Preencha Webhook address on your site. Tem de ser um URL https:// num nome de anfitrião público: endereços IP e localhost são recusados. O AutoPSEO não segue redirecionamentos, por isso indique o endereço final.
  3. Preencha Your site's address, por exemplo https://example.com. O caminho de cada tipo de conteúdo fica por baixo dele, pelo que um artigo de um tipo em /answers fica em https://example.com/answers/<slug>.
  4. Escolha Save and send a test. O AutoPSEO cria um segredo de assinatura (whsec_ seguido de 48 carateres hexadecimais) e faz ping ao seu endereço. Um recetor que verifique assinaturas falha este primeiro ping, porque ainda não tem o segredo.
  5. Mostre o Signing secret com Show ou Copy, guarde-o como segredo no seu site e, depois, escolha Send a test.
  6. Leia What your site handles: cada comando mostra Connected, Not connected, Declared, not tested ou Not tested.

As novas propriedades começam com o AutoPSEO a alojar os artigos no seu próprio endereço (veja Alojamento), e o alojamento e o webhook podem funcionar em paralelo. Quando o seu site já mostrar os artigos, escolha Stop hosting on AutoPSEO: todos os artigos ativos passam para o endereço do seu site e são enviados ao seu webhook como article.published. Esse botão exige um webhook que tenha respondido ao último teste e o endereço do seu site. Enquanto o alojamento estiver desativado, o webhook não pode ser removido.

New secret substitui o segredo de assinatura de imediato e o antigo deixa de verificar logo a seguir. Coloque o novo segredo no seu site logo após o gerar.

O AutoPSEO só coloca entregas em fila para um webhook cujo último ping tenha obtido uma resposta 2xx. Um ping falhado (ao guardar, em Send a test ou no início de uma análise do site) marca o webhook como com erro, e os artigos publicados enquanto está com erro não ficam em fila para ele. Mantenha o ping a responder e escolha Send a test depois de corrigir uma falha.

Cabeçalhos e assinatura

Cada pedido é um POST com corpo JSON e estes cabeçalhos:

CabeçalhoValor
Content-Typeapplication/json
User-AgentAutoPSEO-Webhook/1
X-AutoPSEO-EventO nome do evento, que também vai no corpo como event
X-AutoPSEO-DeliveryO id da entrega, que também vai no corpo como id. É o mesmo em todas as novas tentativas de uma entrega
X-AutoPSEO-TimestampHora Unix em segundos em que esta tentativa foi assinada
X-AutoPSEO-Signaturesha256= seguido do HMAC-SHA256 hexadecimal de <timestamp>.<raw body>

Para verificar um pedido:

  1. Junte o cabeçalho de timestamp, um ponto final e o corpo bruto do pedido tal como chegou, antes de qualquer análise de JSON.
  2. Calcule o HMAC-SHA256 sobre essa cadeia. A chave é o segredo de assinatura completo, tal como a aplicação o mostra, com o prefixo whsec_, em bytes UTF-8.
  3. Codifique o resultado em hexadecimal e compare-o, em tempo constante, com a parte de X-AutoPSEO-Signature a seguir a sha256=. Rejeite o pedido se forem diferentes.

Cada tentativa é assinada de novo com o seu próprio timestamp, por isso pode recusar pedidos cujo timestamp esteja muito afastado do seu relógio (cinco minutos é uma janela habitual) sem prejudicar as novas tentativas. O próprio HMAC está definido na RFC 2104.

Todos os corpos incluem id, event, occurredAt (ISO 8601, UTC) e site: { propertyId, domain }.

Eventos

EventoTipoQuando o AutoPSEO o envia
pingVerificaçãoAo guardar o webhook, em Send a test e no início de cada análise do site
testVerificaçãoLogo após um ping bem-sucedido, um por comando
article.publishedEntregaUm artigo entra no ar pela primeira vez; todos os artigos ativos quando deixa de usar o alojamento
article.updatedEntregaEntra no ar uma nova revisão (atualização, otimização, reversão, nova publicação após uma retirada, idiomas adicionados) ou foi adicionada uma capa
article.unpublishedEntregaUm artigo é retirado ou fundido noutra página
path.addEntregaFoi aprovado um novo valor de um tipo de conteúdo tipado
path.removeEntregaAlguém pediu que o caminho de um valor fosse retirado
path.checkChamadaAntes de o AutoPSEO planear uma página num endereço em que o seu site não tem página, ou propor um novo valor
site.exportChamadaDurante uma análise do site, na sincronização diária de tópicos e antes de o AutoPSEO alterar um artigo ativo
articles.adoptedChamadaDepois de uma análise ter importado artigos que o seu site exportou
site.handoverChamadaQuando alguém entrega a escrita de tipos de conteúdo ao AutoPSEO, ou a devolve

As entregas passam por uma caixa de saída (outbox): são registadas no mesmo lote da base de dados que a alteração, enviadas de imediato, repetidas se necessário e podem ficar em espera pelo seu site. As chamadas são simples pedido e resposta: o AutoPSEO lê a resposta no momento. Quando os comandos de caminho são enviados está explicado em Páginas tipadas e novos caminhos.

O payload do artigo

article.published e article.updated transportam o artigo completo, com todos os idiomas:

{
  "id": "0f6d2c1e-7a51-4c1b-9d0e-5b8f3a2e9c41",
  "event": "article.published",
  "occurredAt": "2026-10-12T08:30:00.000Z",
  "site": { "propertyId": "prop_123", "domain": "example.com" },
  "article": {
    "id": "a4c09e2b-6f1d-4e57-8b3a-1d2e3f4a5b6c",
    "slug": "plan-a-content-calendar",
    "path": "/answers/plan-a-content-calendar",
    "type": { "key": "answers", "name": "Answers", "path": "/answers" },
    "lang": "en",
    "langs": ["en", "de"],
    "rev": 1,
    "url": "https://example.com/answers/plan-a-content-calendar",
    "datePublished": "2026-10-12",
    "dateModified": "2026-10-12",
    "contentHash": "9f2c4e1a7b3d5f6081a2c3d4",
    "writer": "platform",
    "category": "Planning",
    "copy": {
      "title": "How to plan a content calendar",
      "metaDescription": "A content calendar in five steps, with a template.",
      "h1": "How to plan a content calendar",
      "directAnswer": "List your topics, give each a date and an owner, then review the plan weekly.",
      "keyTakeaways": ["Plan a quarter at a time.", "Give every piece one owner."],
      "sections": [{ "heading": "Start from your topics", "bodyHtml": "<p>Begin with the searches you want to win.</p>" }],
      "faq": [{ "q": "How far ahead should I plan?", "a": "One quarter is enough for most teams." }],
      "sources": [{ "title": "Creating helpful content", "url": "https://developers.google.com/search/docs/fundamentals/creating-helpful-content" }]
    },
    "translations": { "de": { "title": "Einen Redaktionsplan erstellen", "h1": "Einen Redaktionsplan erstellen" } },
    "cover": {
      "url": "https://app.autopseo.com/media/covers/prop_123/<sha256>.jpg",
      "sha256": "<sha256>",
      "contentType": "image/jpeg",
      "width": 1536,
      "height": 1024
    }
  }
}

A entrada em alemão está abreviada aqui; cada tradução tem a mesma forma que copy.

CampoSignificado
idO id do artigo no AutoPSEO. Nunca muda, por isso é uma chave segura
slugA última parte do endereço. Em páginas tipadas contém segmentos unidos por /, como google/software-engineer
pathO endereço relativo ao seu site: o caminho do tipo mais o slug. Fica fixo depois de a página existir
type{ key, name, path }. key é estável; path é onde ficam os artigos do tipo (vazio para a raiz do site)
lang, langsO idioma principal e todos os idiomas desta revisão
revO número da revisão
urlO endereço completo, tal como o AutoPSEO o conhece: o do seu site depois de o alojamento ser desativado, ou o url que o seu recetor respondeu pela última vez
datePublished, dateModifiedYYYY-MM-DD. A data de publicação mantém-se fixa nas revisões seguintes
contentHashUma impressão digital da revisão
writerQuem escreveu a revisão: platform (AutoPSEO), mcp (a sua própria IA), user, import (o texto do seu próprio site) ou aggregate (uma página de lista)
copyO artigo no idioma principal
translationsOs outros idiomas, indexados pelo código de idioma
categoryA categoria do seu site para o artigo, quando o seu formato mantém categorias
topic{ externalId, keyword } quando o artigo foi escrito para um tópico que o seu site entregou, para que possa fechar esse tópico
partsEm páginas tipadas, o valor de cada parte: { "company": "google", "role": "software-engineer" }
kind"list" numa página de lista. O seu copy é { title, h1, items: [{ from, text }] }, em que from é o caminho da página de onde vem cada item, e parts indica o valor que reúne
coverUma capa que o AutoPSEO criou para o artigo. Obtenha-a uma vez e guarde a sua própria cópia; o endereço nunca muda, porque o nome do ficheiro é o SHA-256 da imagem
basedOnEm article.updated: a versão da sua página a partir da qual esta revisão foi escrita (veja os conflitos abaixo)
redirectToEm article.unpublished após uma fusão: para onde o endereço antigo passa a levar. Responda com um 301 e não coloque nada de novo ali

article.unpublished transporta os mesmos campos de article, exceto copy, translations, category e cover.

Dentro de copy

CampoSignificado
title, metaDescription, h1O título da página, a meta description e o cabeçalho
directAnswerA resposta curta mostrada em primeiro lugar
keyTakeawaysUma lista de cadeias de texto
sections[{ heading, bodyHtml }]
faq[{ q, a }]
sources[{ title, url, lastVerified? }]
questionOpcional: a pergunta de quem pesquisa, nas suas palavras, quando o seu formato a pede
categoryOpcional: uma das categorias do seu site
listsOpcional: listas em texto simples por chave, para tipos de conteúdo que as pedem (as perguntas de exemplo de um guia de entrevista)

Nos artigos que o AutoPSEO escreve, bodyHtml usa apenas p, ul, li, strong, em, a e code, e as ligações mantêm apenas o href. O texto aproveitado do seu próprio site mantém o seu HTML. Passe-o também pelo seu próprio sanitizador, se os seus templates o exigirem.

O que o seu recetor responde

Qualquer 2xx conclui uma entrega. O AutoPSEO lê também um objeto JSON opcional do corpo da resposta; mantenha-o abaixo de 64 KB, porque uma resposta maior conta como falha.

CampoEfeito
urlO endereço https:// onde o artigo passa a estar. O AutoPSEO regista-o como o URL do artigo
versionA versão da sua página, uma cadeia opaca de, no máximo, 128 carateres. Volta como basedOn na atualização seguinte
urlsOs endereços https:// que esta entrega alterou, para o IndexNow (todos os idiomas, ou os endereços que deixou de servir). [] significa que nada mudou
cover"wanted" quando não tem capa para o artigo e a entrega não trazia nenhuma. O AutoPSEO cria uma e envia-a num article.updated

As respostas de erro têm significados diferentes:

A sua respostaComo o AutoPSEO a interpretaO que acontece
Um 4xx com { "error": "unknown_event" }, ou um 501Ainda não trata este comandoA entrega fica em espera, sem novas tentativas
Um 409 com { "error": "conflict" }A atualização foi escrita a partir de uma versão que a sua página já não temA entrega falha sem novas tentativas, e o artigo é lido de novo a partir do seu site antes da alteração seguinte
Um 4xx que não seja 408 nem 429, com { "error": "<code>", "details": [] }, em resposta a path.add ou path.removeUma recusa com o seu motivoFica registada; veja Páginas tipadas e novos caminhos
Qualquer outra coisa: sem resposta em 10 segundos, um erro de rede, um redirecionamento, 408, 429, 5xx ou outro 4xxUma falha temporáriaNova tentativa com intervalos crescentes

Novas tentativas e entregas substituídas

Uma entrega é enviada assim que a alteração é guardada, e um agendamento de cinco minutos envia tudo o que estiver pendente, começando pelo mais antigo. Após uma falha temporária, o AutoPSEO espera 1 minuto e depois 5 minutos, 15 minutos, 1 hora, 3 horas, 6 horas e 12 horas entre tentativas. A oitava tentativa falhada marca a entrega como falhada, e a página Content conta-a num distintivo vermelho junto ao webhook.

Quando existe uma entrega mais recente para o mesmo artigo, uma mais antiga ainda pendente é marcada como substituída e nunca é enviada. Cada entrega transporta o artigo completo, por isso a mais recente é tudo o que o seu site precisa. Os comandos de caminho sobre o mesmo valor funcionam da mesma forma.

O seu recetor pode ver uma entrega duas vezes, por exemplo quando guardou o artigo mas respondeu depois do limite de 10 segundos. Elimine duplicados com base em X-AutoPSEO-Delivery, ou torne cada escrita idempotente guardando por article.id. O article.updated pode também chegar a um artigo que apagou depois de um article.unpublished, porque voltar a publicar um artigo retirado envia uma atualização: trate as atualizações como "criar ou substituir".

Comandos que o seu site ainda não trata

Um site pode implementar o protocolo um comando de cada vez. O AutoPSEO guarda o estado de cada comando e fica a saber-o de três formas:

  • Testes. Após cada ping bem-sucedido, o AutoPSEO envia um test por comando. O corpo é { id, event: "test", occurredAt, site, test: { event: "<command>", ... } }, em que test contém um exemplo com a forma do comando real e relativo a <type path>/autopseo-connection-test, um endereço que nenhum site tem. Verifique-o como faria com o comando real, não altere nada e responda com um 2xx: { "test": "<command>", "ok": true } para Connected, { "test": "<command>", "ok": false, "error": "unknown_event" } para Not connected. Uma resposta com ok: false e outro código de erro, como a retirada de um endereço que não tem, conta na mesma como ligado. O exemplo fica sob test, nunca ao nível superior, para que um recetor que não saiba nada de testes não encontre ali nenhum artigo.
  • Entregas e chamadas reais. Um 2xx marca o comando como ligado; unknown_event marca-o como não ligado.
  • A resposta ao ping. Os comandos que a sua resposta lista em autopseo.events aparecem como Declared, not tested até que um teste ou uma entrega digam mais.

Uma entrega para um comando que não está ligado fica em espera. A página Content lista o que está em espera, por exemplo "Your site does not take articles down yet", com um botão Done on the site para tudo o que tenha feito à mão; o AutoPSEO nunca envia essa. Quando um teste ou uma entrega posterior encontrar o comando ligado, tudo o que estava à espera dele é enviado.

A resposta ao ping

Responda ao ping com um 2xx. Um site que apenas recebe artigos não precisa de mais nada. Para dizer ao AutoPSEO o que mais o seu site faz, inclua um objeto autopseo:

{
  "ok": true,
  "autopseo": {
    "version": 1,
    "events": ["article.published", "article.updated", "article.unpublished"],
    "covers": true,
    "indexNow": { "key": "3f1c9a7e5b2d4f60", "keyLocation": "https://example.com/3f1c9a7e5b2d4f60.txt" },
    "export": ["articles", "topics", "profile"],
    "adopt": true,
    "handover": true,
    "types": ["answers"],
    "writer": "site"
  }
}
CampoSignificado
version1. O objeto é ignorado sem uma versão igual ou superior a 1
eventsOs comandos que trata, mostrados como declarados até serem testados
coverstrue quando as suas páginas mostram uma capa com cada artigo. O redator do AutoPSEO cria então a capa enquanto escreve, para que a primeira entrega a traga, e é pedido à sua própria IA que forneça uma. Veja Capas
indexNow{ key, keyLocation? }: a chave IndexNow que serve (8 a 128 letras, dígitos e hífenes) e, quando o ficheiro da chave não está em /<key>.txt, a sua localização https://
exportO que site.export entrega: articles, topics, profile
adoptAceita articles.adopted, para que uma análise completa possa assumir os seus artigos existentes
handoverAceita site.handover
typesAs chaves dos tipos de conteúdo que aceita (até 50)
writerQuem escreve hoje os seus novos artigos: site ou autopseo

IndexNow para os endereços que serve

Quando a sua resposta ao ping declara indexNow, o AutoPSEO faz ping ao IndexNow em api.indexnow.org depois das entregas que aceita, e o seu site não faz ping a nada por si.

  • Envia os urls que a sua resposta listou. Sem urls, envia o url da sua resposta; na falta deste, o URL do artigo. urls: [] não envia nada.
  • Só entram endereços no seu próprio anfitrião, como exige o protocolo IndexNow: o anfitrião de keyLocation ou, na falta dele, o anfitrião do endereço do seu site.
  • Cada ciclo de entrega envia um ping por site, com até 10,000 endereços.
  • 200 e 202 são aceites. Um 429, um 5xx ou a ausência de resposta mantém os endereços e tenta de novo passada uma hora, duplicando de cada vez até um dia (mais tempo quando Retry-After o pede), com o que as entregas seguintes acrescentarem. Um 403 (chave inválida) ou 422 (endereços que não pertencem ao anfitrião) é registado e descartado.
  • A página Content mostra o último ping: quando, quantos endereços, o estado HTTP e a próxima tentativa.
  • Enquanto o AutoPSEO também aloja os artigos, as páginas alojadas recebem ping com a chave do próprio AutoPSEO, e a sua não é usada.

A ferramenta IndexNow, em Indexing, é um submissor manual à parte; veja IndexNow.

Conflitos: o texto do seu site é o original

A version da sua resposta (ou a version de um artigo na sua exportação) é aquilo em que o AutoPSEO acredita que a sua página está. Cada article.updated transporta-a como basedOn. Se a página mudou entretanto no seu site, por exemplo após uma correção de um editor, responda 409 com { "error": "conflict" } e não guarde nada. A entrega falha sem novas tentativas.

Antes de escrever uma alteração em fila para um artigo ativo, o AutoPSEO lê esse artigo de novo a partir do seu site com site.export e paths, quando o seu site trata site.export. Se a sua versão avançou, o seu texto passa a ser a revisão seguinte do artigo (writer import), e a atualização que se segue traz a sua versão atual como basedOn.

path.add e path.remove

Estas duas entregas dizem respeito a um valor de um tipo de conteúdo tipado, por exemplo uma empresa em /interviews/{company}/{role}. Transportam um objeto path em vez de article:

{
  "id": "7c1e9d2a-3b4f-4e8a-9c0d-1e2f3a4b5c6d",
  "event": "path.add",
  "occurredAt": "2026-10-12T08:30:00.000Z",
  "site": { "propertyId": "prop_123", "domain": "example.com" },
  "path": {
    "path": "/interviews/jane-street",
    "type": { "key": "interviews", "name": "Interviews", "path": "/interviews" },
    "part": "company",
    "value": {
      "value": "jane-street",
      "name": "Jane Street",
      "aliases": [],
      "url": "https://<its own site>/",
      "sameAs": ["https://<its careers page>/"],
      "description": "<what it is, in a few words>",
      "facts": [
        { "text": "<one sentence, as the source states it>", "source": "https://<the page that says it>", "lastVerified": "2026-10-01", "tier": "official" }
      ]
    },
    "takes": { "role": ["software-engineer"] }
  }
}
CampoSignificado
pathO endereço próprio do valor no seu site
typeO tipo de conteúdo, como { key, name, path }
partA parte do endereço que o valor preenche
value{ value, name } e, em path.add, os aliases, url, sameAs, description e facts que o redator do AutoPSEO reuniu. Cada facto tem text, source, lastVerified e um tier de official ou reported
takesEm path.add: os valores das outras partes que as pessoas pesquisam em conjunto com ele
redirectToEm path.remove: para onde os endereços do valor devem passar a levar, com um 301

Responda com um 2xx quando tiver aceitado o comando; em path.add pode acrescentar o url onde o valor passa a estar e, em path.remove, os urls que deixou de servir, que seguem para o IndexNow. Para recusar, responda com um 4xx com { "error": "<code>", "details": ["<why>"] }. Em path.add, invalid_value devolve o valor ao redator com o seu motivo (a terceira recusa é definitiva) e qualquer outro código recusa-o em definitivo. Em path.remove, not_found conta como concluído, e qualquer outro código, como o 409 in_use, deixa tudo como estava. Marcar um comando de caminho em espera como Done on the site conta como o sim do seu site.

Chamadas: pedidos cuja resposta o AutoPSEO lê

As chamadas são assinadas como as entregas. Uma chamada espera até 60 segundos por omissão e 30 segundos nas releituras e verificações de caminho, e lê respostas até 24 MB. Qualquer resposta que não seja 2xx faz a chamada falhar.

site.export

O pedido transporta export: { what, cursor, limit }, mais since (uma hora ISO) para tópicos ou paths para uma releitura. Responda { "items": [], "next": "<cursor>" }; o AutoPSEO volta a perguntar com cursor igual ao seu next até este estar vazio ou ser null.

  • what: "articles" corre nas análises completas do site, 10 por página, quando declarou export com articles e adopt: true. Uma resposta 400 significa "não suportado". Com paths: ["/answers/x"] e limit: 1 volta a ler um artigo; responda apenas com esse artigo.
  • what: "topics" corre em todas as análises e na sincronização diária, 50 por página; a sincronização diária envia since, a hora da última extração.
  • what: "profile" corre em todas as análises. Responda { "profile": { types, languages, rules, categories, format } }, todos opcionais: types como [{ key, name, path }], e rules com brandTerms, synonyms, noise, onTopic e offTopic.

Um artigo exportado precisa de externalId, de um slug (letras e dígitos de qualquer escrita e hífenes, no máximo 200 carateres, segmentos unidos por /), de um path que termine nesse slug e de copy com pelo menos title, h1 e uma secção. Campos opcionais: lang (por omissão en), type (uma chave de tipo de conteúdo), url, keyword, category, datePublished, dateModified, version, translations, owner (o id de artigo do AutoPSEO, depois de adotado) e maintenance: { kind: "refresh" or "optimize", reason }, que coloca essa tarefa em fila. Cada artigo exportado passa a ser um artigo gerido, com o seu texto como primeira revisão, e nada é enviado de volta. Voltar a exportar é seguro, e um artigo que o AutoPSEO retirou nunca regressa através de uma exportação.

Um item de tópico é { keyword, status?, volume?, source?, externalId?, type?, reason? }, em que status é queued, planned ou rejected.

articles.adopted

Depois de importar, o AutoPSEO pede-lhe que o deixe gerir os novos artigos: { "articles": [{ "externalId", "articleId", "slug", "path" }] }. Responda { "adopted": ["<externalId>"], "refused": [{ "externalId": "<id>", "reason": "<why>" }] }. Um artigo que não confirmar é removido de novo do AutoPSEO, a menos que o AutoPSEO já tenha escrito uma revisão dele.

site.handover

{ "handover": { "types": ["answers"], "writer": "autopseo" } }, enviado quando alguém escolhe Let AutoPSEO write them ou Hand writing back to the site na página Your site da propriedade. Responda { "ok": true, "writer": "autopseo" }. Depois de uma entrega ao AutoPSEO, o seu site passa ao AutoPSEO os tópicos que valida, em vez de os escrever.

path.check

{ "paths": [{ "path": "/interviews/google/data-engineer" }, { "path": "/interviews/jane-street", "new": { "part": "company", "value": "jane-street", "name": "Jane Street" } }] }. Responda com um resultado por caminho: { "results": [{ "path": "/interviews/google/data-engineer", "ok": true }, { "path": "/interviews/jane-street", "ok": false, "error": "not_allowed", "details": ["We only list companies with a careers page."] }] }. Uma recusa é guardada com o seu motivo e não volta a ser perguntada; um caminho que omitir é perguntado de novo mais tarde.

Um recetor mínimo

Um Cloudflare Worker que verifica a assinatura com a Web Crypto API, trata os três comandos de artigo e recusa os restantes como unknown_event. Guarda os artigos num espaço de nomes KV associado como ARTICLES, e o segredo de assinatura num segredo do Worker chamado AUTOPSEO_SECRET.

const HANDLED = ["article.published", "article.updated", "article.unpublished"];
const MAX_SKEW_SECONDS = 300;
const SITE = "https://example.com";

const json = (data, status = 200) =>
  new Response(JSON.stringify(data), { status, headers: { "content-type": "application/json" } });

function hexToBytes(hex) {
  if (!/^[0-9a-f]{64}$/.test(hex)) return null;
  const out = new Uint8Array(32);
  for (let i = 0; i < 32; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
  return out;
}

async function verified(request, body, secret) {
  const timestamp = request.headers.get("X-AutoPSEO-Timestamp") || "";
  const signature = (request.headers.get("X-AutoPSEO-Signature") || "").replace(/^sha256=/, "");
  if (!/^\d+$/.test(timestamp)) return false;
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > MAX_SKEW_SECONDS) return false;
  const bytes = hexToBytes(signature);
  if (!bytes) return false;
  const enc = new TextEncoder();
  const key = await crypto.subtle.importKey("raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["verify"]);
  // verify() compares in constant time.
  return crypto.subtle.verify("HMAC", key, bytes, enc.encode(`${timestamp}.${body}`));
}

export default {
  async fetch(request, env) {
    if (request.method !== "POST") return new Response("Method not allowed", { status: 405 });
    const body = await request.text();
    if (!(await verified(request, body, env.AUTOPSEO_SECRET))) return json({ error: "bad_signature" }, 401);
    let msg;
    try {
      msg = JSON.parse(body);
    } catch {
      return json({ error: "bad_json" }, 400);
    }

    if (msg.event === "ping") return json({ ok: true, autopseo: { version: 1, events: HANDLED } });

    if (msg.event === "test") {
      const command = msg.test && msg.test.event;
      // Check the sample as you would the real command, and store nothing.
      return HANDLED.includes(command)
        ? json({ test: command, ok: true })
        : json({ test: command, ok: false, error: "unknown_event" });
    }

    if (msg.event === "article.published" || msg.event === "article.updated") {
      const a = msg.article;
      const stored = JSON.parse((await env.ARTICLES.get(a.id)) || "null");
      if (a.basedOn && stored && stored.version && stored.version !== a.basedOn) {
        return json({ error: "conflict", details: ["The page changed on the site."] }, 409);
      }
      // Any change to the page, here or in your own editor, gets a new version.
      const version = crypto.randomUUID();
      await env.ARTICLES.put(a.id, JSON.stringify({ ...a, version }));
      return json({ url: SITE + a.path, version });
    }

    if (msg.event === "article.unpublished") {
      const a = msg.article;
      if (a.redirectTo) await env.ARTICLES.put(a.id, JSON.stringify({ path: a.path, redirectTo: a.redirectTo }));
      else await env.ARTICLES.delete(a.id);
      return json({ ok: true });
    }

    return json({ error: "unknown_event" }, 400);
  },
};

No Node.js, verifique da mesma forma com createHmac e timingSafeEqual, sobre o corpo bruto que a sua framework recebeu:

import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyAutopseo(rawBody, headers, secret) {
  const timestamp = String(headers["x-autopseo-timestamp"] || "");
  const given = String(headers["x-autopseo-signature"] || "");
  if (!/^\d+$/.test(timestamp) || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
  const expected = "sha256=" + createHmac("sha256", secret).update(timestamp + ".").update(rawBody).digest("hex");
  return given.length === expected.length && timingSafeEqual(Buffer.from(given), Buffer.from(expected));
}

A seguir: o que chega ao webhook é decidido pela escrita e revisão e pela manutenção; o fluxo completo está na visão geral do motor de conteúdo.

Coloque o seu conteúdo de SEO no piloto automático

Conecte o Search Console e o seu site. O AutoPSEO analisa tudo e planeja os primeiros temas na hora. Sem cartão de crédito.