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.
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
- Abra a página Content da propriedade e vá a Where articles are published.
- Preencha Webhook address on your site. Tem de ser um URL
https://num nome de anfitrião público: endereços IP elocalhostsão recusados. O AutoPSEO não segue redirecionamentos, por isso indique o endereço final. - 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/answersfica emhttps://example.com/answers/<slug>. - 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. - Mostre o Signing secret com Show ou Copy, guarde-o como segredo no seu site e, depois, escolha Send a test.
- 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çalho | Valor |
|---|---|
Content-Type | application/json |
User-Agent | AutoPSEO-Webhook/1 |
X-AutoPSEO-Event | O nome do evento, que também vai no corpo como event |
X-AutoPSEO-Delivery | O id da entrega, que também vai no corpo como id. É o mesmo em todas as novas tentativas de uma entrega |
X-AutoPSEO-Timestamp | Hora Unix em segundos em que esta tentativa foi assinada |
X-AutoPSEO-Signature | sha256= seguido do HMAC-SHA256 hexadecimal de <timestamp>.<raw body> |
Para verificar um pedido:
- 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.
- 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. - Codifique o resultado em hexadecimal e compare-o, em tempo constante, com a parte de
X-AutoPSEO-Signaturea seguir asha256=. 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
| Evento | Tipo | Quando o AutoPSEO o envia |
|---|---|---|
ping | Verificação | Ao guardar o webhook, em Send a test e no início de cada análise do site |
test | Verificação | Logo após um ping bem-sucedido, um por comando |
article.published | Entrega | Um artigo entra no ar pela primeira vez; todos os artigos ativos quando deixa de usar o alojamento |
article.updated | Entrega | Entra 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.unpublished | Entrega | Um artigo é retirado ou fundido noutra página |
path.add | Entrega | Foi aprovado um novo valor de um tipo de conteúdo tipado |
path.remove | Entrega | Alguém pediu que o caminho de um valor fosse retirado |
path.check | Chamada | Antes 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.export | Chamada | Durante uma análise do site, na sincronização diária de tópicos e antes de o AutoPSEO alterar um artigo ativo |
articles.adopted | Chamada | Depois de uma análise ter importado artigos que o seu site exportou |
site.handover | Chamada | Quando 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.
| Campo | Significado |
|---|---|
id | O id do artigo no AutoPSEO. Nunca muda, por isso é uma chave segura |
slug | A última parte do endereço. Em páginas tipadas contém segmentos unidos por /, como google/software-engineer |
path | O 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, langs | O idioma principal e todos os idiomas desta revisão |
rev | O número da revisão |
url | O 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, dateModified | YYYY-MM-DD. A data de publicação mantém-se fixa nas revisões seguintes |
contentHash | Uma impressão digital da revisão |
writer | Quem 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) |
copy | O artigo no idioma principal |
translations | Os outros idiomas, indexados pelo código de idioma |
category | A 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 |
parts | Em 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 |
cover | Uma 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 |
basedOn | Em article.updated: a versão da sua página a partir da qual esta revisão foi escrita (veja os conflitos abaixo) |
redirectTo | Em 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
| Campo | Significado |
|---|---|
title, metaDescription, h1 | O título da página, a meta description e o cabeçalho |
directAnswer | A resposta curta mostrada em primeiro lugar |
keyTakeaways | Uma lista de cadeias de texto |
sections | [{ heading, bodyHtml }] |
faq | [{ q, a }] |
sources | [{ title, url, lastVerified? }] |
question | Opcional: a pergunta de quem pesquisa, nas suas palavras, quando o seu formato a pede |
category | Opcional: uma das categorias do seu site |
lists | Opcional: 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.
| Campo | Efeito |
|---|---|
url | O endereço https:// onde o artigo passa a estar. O AutoPSEO regista-o como o URL do artigo |
version | A versão da sua página, uma cadeia opaca de, no máximo, 128 carateres. Volta como basedOn na atualização seguinte |
urls | Os 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 resposta | Como o AutoPSEO a interpreta | O que acontece |
|---|---|---|
Um 4xx com { "error": "unknown_event" }, ou um 501 | Ainda não trata este comando | A 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 tem | A 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.remove | Uma recusa com o seu motivo | Fica 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 4xx | Uma falha temporária | Nova 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
testpor comando. O corpo é{ id, event: "test", occurredAt, site, test: { event: "<command>", ... } }, em quetestconté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 comok: falsee outro código de erro, como a retirada de um endereço que não tem, conta na mesma como ligado. O exemplo fica sobtest, 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_eventmarca-o como não ligado. - A resposta ao ping. Os comandos que a sua resposta lista em
autopseo.eventsaparecem 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"
}
}
| Campo | Significado |
|---|---|
version | 1. O objeto é ignorado sem uma versão igual ou superior a 1 |
events | Os comandos que trata, mostrados como declarados até serem testados |
covers | true 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:// |
export | O que site.export entrega: articles, topics, profile |
adopt | Aceita articles.adopted, para que uma análise completa possa assumir os seus artigos existentes |
handover | Aceita site.handover |
types | As chaves dos tipos de conteúdo que aceita (até 50) |
writer | Quem 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
urlsque a sua resposta listou. Semurls, envia ourlda 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
keyLocationou, 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-Aftero 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"] }
}
}
| Campo | Significado |
|---|---|
path | O endereço próprio do valor no seu site |
type | O tipo de conteúdo, como { key, name, path } |
part | A 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 |
takes | Em path.add: os valores das outras partes que as pessoas pesquisam em conjunto com ele |
redirectTo | Em 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 declarouexportcomarticleseadopt: true. Uma resposta 400 significa "não suportado". Compaths: ["/answers/x"]elimit: 1volta 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 enviasince, a hora da última extração.what: "profile"corre em todas as análises. Responda{ "profile": { types, languages, rules, categories, format } }, todos opcionais:typescomo[{ key, name, path }], erulescombrandTerms,synonyms,noise,onTopiceoffTopic.
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.