Pular para o conteúdo
SEO

IndexNow: avisando o Bing e o Yandex mais rápido

Ilustração colorida de um unicórnio de crina arco-íris soprando uma trombeta que dispara pergaminhos de URL até dois castelos de buscadores, com uma coruja carregando uma chave dourada e velas flutuantes ao redor

Olá meus Unicórnios! 🦄✨

Sabe aquela sensação de publicar um artigo novo, olhar para a tela toda orgulhosa, e depois ficar esperando? 😅 Esperando o buscador passar. Esperando ele reparar que a página existe. Às vezes um dia, às vezes uma semana, às vezes nunca.

É que o mundo dos buscadores sempre funcionou por visita. O robô decide quando volta ao seu site, e você não tem voz nenhuma nisso. Publicou? Ótimo, agora aguarde. 🙃

O IndexNow vira essa lógica do avesso: em vez de esperar o robô voltar, você bate na porta dele e avisa. "Ó, esta página aqui mudou, dá uma olhada." E o mais bonito é que isso cabe numa requisição HTTP só, sem SDK, sem cadastro, sem painel para configurar.

Este artigo é o caminho inteiro: como criar a chave, como publicar o arquivo de verificação e como mandar as URLs, com um script em Node.js e outro em PHP. E, principalmente, quais erros a API devolve quando você escorrega, porque alguns deles são bem traiçoeiros.

🎺 Por que isso importa (e por que não é bala de prata)

A ideia do protocolo é ridiculamente simples: você manda uma lista de URLs que mudaram, e os buscadores que participam recebem o aviso. Sem esperar o rastreamento. Sem torcer para o robô ter um dia de boa vontade.

E tem um detalhe que quase ninguém menciona: o IndexNow não serve só para página nova. Ele serve para qualquer mudança que você queira que o buscador saiba:

  • Publicou um artigo. O caso óbvio.
  • Editou um artigo antigo, corrigiu um preço, atualizou um tutorial que ficou desatualizado.
  • Apagou uma página. Sim, isso também: você avisa a URL, o buscador vai lá, vê o 404 e tira do índice mais rápido do que faria sozinho.

Agora a parte chata, e prefiro dizer logo em vez de te deixar descobrir depois. 🙏

O Google não usa o IndexNow. Ele chegou a anunciar que estava avaliando o protocolo, e ficou nisso. Para o Google, continua valendo o sitemap e o Search Console, do mesmo jeito de sempre.

Então por que fazer? Porque é trabalho de uma vez só. São dez minutos para criar a chave e subir o arquivo, mais um script que você chama sempre que publica. Depois disso, roda sozinho para sempre. Não é a coisa mais importante do seu SEO, mas é provavelmente a de melhor relação entre esforço e retorno que existe.

🔑 A chave: você inventa, ninguém aprova

Esta é a parte que mais confunde quem chega, porque a gente está viciada em API que exige cadastro. No IndexNow não existe cadastro. Não tem painel, não tem e-mail de confirmação, não tem botão "gerar credencial".

Você inventa a chave. Sério. Ela é só um texto aleatório que você escolhe, e a única coisa que prova que ela é sua é o fato de ela estar publicada dentro do seu site, num lugar onde só quem manda no domínio conseguiria colocar.

As regras que a chave precisa obedecer são estas, e elas vêm da própria mensagem de erro da API:

  • De 8 a 128 caracteres.
  • Só letras minúsculas (a-z), maiúsculas (A-Z), números (0-9) e traço (-).

Nada de acento, espaço, underline ou símbolo. O jeito mais prático de acertar sem pensar é gerar um punhado de caracteres hexadecimais, porque hexadecimal só tem número e letra de a a f, então cai naturalmente dentro da regra.

Este script gera a chave e já cria o arquivo de verificação junto:

// Gera a chave do IndexNow e grava o arquivo de verificacao.
// A chave e o nome do arquivo TEM de ser iguais, senao o buscador recusa.
const crypto = require("crypto");
const fs = require("fs");

function gerarChave() {
    // 32 caracteres hexadecimais. So letras e numeros, como a API exige.
    return crypto.randomBytes(16).toString("hex");
}

function main() {
    const chave = gerarChave();

    // O conteudo do arquivo e a propria chave, mais nada. Sem quebra de linha
    // extra, sem aspas, sem JSON.
    fs.writeFileSync(chave + ".txt", chave);

    console.log("Sua chave e: " + chave);
    console.log("Arquivo criado: " + chave + ".txt");
}

main();

Rodando com node gerar-chave.js, a saída é assim:

Sua chave e: 7c4bba7aa9ef3dfbea5dc9c481bf80f7
Arquivo criado: 7c4bba7aa9ef3dfbea5dc9c481bf80f7.txt

E se você preferir PHP, é a mesma ideia em três linhas:

<?php
// Gera a chave do IndexNow e grava o arquivo de verificacao.

function gerarChave()
{
    // 32 caracteres hexadecimais. So letras e numeros, como a API exige.
    return bin2hex(random_bytes(16));
}

$chave = gerarChave();

// O conteudo do arquivo e a propria chave, mais nada.
file_put_contents($chave . '.txt', $chave);

echo 'Sua chave e: ' . $chave . "\n";
echo 'Arquivo criado: ' . $chave . ".txt\n";

📄 O arquivo de verificação: o nome é a chave

Aqui mora a armadilha que derruba mais gente no primeiro dia, e ela é boba de tão simples: o nome do arquivo e o conteúdo dele são a mesma coisa.

Se a sua chave é 7c4bba7aa9ef3dfbea5dc9c481bf80f7, então:

  • o arquivo se chama 7c4bba7aa9ef3dfbea5dc9c481bf80f7.txt;
  • e dentro dele está escrito 7c4bba7aa9ef3dfbea5dc9c481bf80f7, mais nada.

Mais nada mesmo. Sem aspas, sem JSON, sem título, sem linha em branco depois. Só a chave crua. É por isso que o script acima usa writeFileSync(chave + ".txt", chave) em vez de console.log com redirecionamento: o echo do terminal costuma acrescentar uma quebra de linha no fim, e aí o arquivo deixa de bater exatamente com o nome.

Esse arquivo vai na raiz do seu site, de forma que ele responda nesta URL:

https://exemplo.com.br/7c4bba7aa9ef3dfbea5dc9c481bf80f7.txt

E antes de seguir, abra essa URL no navegador. Ela tem de mostrar a chave na tela, em texto puro. Se aparecer a sua página de 404 bonitinha, ou se o servidor devolver o arquivo como download em vez de exibir, o buscador não vai conseguir validar e todos os seus envios vão ser silenciosamente ignorados.

Pelo terminal, dá para conferir assim:

curl -s -o /dev/null -w "%{http_code} %{content_type}\n" \
  "https://exemplo.com.br/7c4bba7aa9ef3dfbea5dc9c481bf80f7.txt"

Você quer ver 200 e um tipo de conteúdo de texto. Qualquer outra coisa e é melhor resolver isso antes de continuar.

📮 Mandando as URLs: a requisição de verdade

Com a chave publicada, o envio é um POST só. O corpo é um JSON com três campos:

{
    "host": "exemplo.com.br",
    "key": "7c4bba7aa9ef3dfbea5dc9c481bf80f7",
    "urlList": [
        "https://exemplo.com.br/meu-artigo-novo/",
        "https://exemplo.com.br/uma-pagina-que-mudou/"
    ]
}

O host é o seu domínio sem o https://. O key é a chave. E o urlList é a lista de endereços completos, esses sim com o https://. Essa diferença entre um campo e outro parece pegadinha, mas é assim mesmo.

Do jeito mais direto possível, sem escrever nenhum script, é isto:

curl -X POST "https://api.indexnow.org/indexnow" \
  -H "Content-Type: application/json; charset=utf-8" \
  -d '{"host":"exemplo.com.br","key":"7c4bba7aa9ef3dfbea5dc9c481bf80f7","urlList":["https://exemplo.com.br/meu-artigo-novo/"]}'

Se der certo, a resposta é um 202 com o corpo vazio. Nenhuma mensagem, nenhum JSON de confirmação, nada. O silêncio é o sucesso aqui. 😄

🟢 Node.js: o script que você chama ao publicar

Este é o arquivo completo. Ele roda no Node 18 ou mais novo sem instalar absolutamente nada, porque o fetch passou a vir junto com o Node a partir dessa versão. Sem npm install, sem package.json, sem dependência.

// Avisa os buscadores que estas URLs mudaram, usando o IndexNow.
// Roda com Node 18 ou mais novo, sem instalar nada: o fetch ja vem junto.

const HOST = "exemplo.com.br";
const CHAVE = "7c4bba7aa9ef3dfbea5dc9c481bf80f7";

async function enviarUrls(listaDeUrls) {
    const corpo = {
        host: HOST,
        key: CHAVE,
        // Se a chave nao estiver na raiz do site, descomente a linha abaixo:
        // keyLocation: "https://" + HOST + "/" + CHAVE + ".txt",
        urlList: listaDeUrls
    };

    const resposta = await fetch("https://api.indexnow.org/indexnow", {
        method: "POST",
        // Sem este cabecalho a API responde 415 e nao explica o motivo.
        headers: { "Content-Type": "application/json; charset=utf-8" },
        body: JSON.stringify(corpo)
    });

    const texto = await resposta.text();
    return { status: resposta.status, texto: texto };
}

async function main() {
    const urls = [
        "https://" + HOST + "/meu-artigo-novo/",
        "https://" + HOST + "/uma-pagina-que-mudou/"
    ];

    try {
        const r = await enviarUrls(urls);
        console.log("HTTP " + r.status);

        if (r.status === 200 || r.status === 202) {
            console.log("Enviado. " + urls.length + " URLs na fila.");
        } else {
            console.log("A API recusou. Resposta: " + r.texto);
        }
    } catch (erro) {
        console.log("Nao consegui falar com a API: " + erro.message);
    }
}

main();

Três detalhes que valem o parágrafo:

O Content-Type não é opcional. Aquele comentário na linha dele não está lá de enfeite. Sem esse cabeçalho a API responde 415 com o corpo vazio, sem dizer o que faltou. É o tipo de erro que a gente fica meia hora olhando sem entender. 😤

Eu leio a resposta como texto, não como JSON. Repare no resposta.text(). Se eu tivesse escrito resposta.json(), o caminho de sucesso quebraria, porque o 202 vem com corpo vazio e vazio não é JSON válido. O script morreria justamente quando tudo deu certo, que é o bug mais irritante que existe.

O keyLocation é para quem não pode mexer na raiz. Deixei comentado de propósito. Se o seu site não deixa você colocar arquivo na raiz (uma hospedagem compartilhada, um CMS teimoso), você pode pôr o .txt em outra pasta e informar o endereço dele nesse campo. Se o arquivo está na raiz, não precisa mandar nada: a API procura ali sozinha.

Rodando o script, a saída é esta:

HTTP 202
Enviado. 2 URLs na fila.

🐘 PHP: a mesma coisa com curl

Se o seu site é PHP, faz mais sentido chamar o aviso de dentro do próprio site, na hora em que você salva o post. Aqui não precisa de biblioteca nenhuma também: o curl já vem em praticamente toda hospedagem PHP.

<?php
// Avisa os buscadores que estas URLs mudaram, usando o IndexNow.
// So precisa do PHP com a extensao curl, que quase toda hospedagem ja tem.

$HOST  = 'exemplo.com.br';
$CHAVE = '7c4bba7aa9ef3dfbea5dc9c481bf80f7';

function enviarUrls($host, $chave, $listaDeUrls)
{
    $corpo = array(
        'host'    => $host,
        'key'     => $chave,
        'urlList' => $listaDeUrls,
    );

    $ch = curl_init('https://api.indexnow.org/indexnow');
    curl_setopt($ch, CURLOPT_POST, true);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    // Sem este cabecalho a API responde 415 e nao explica o motivo.
    curl_setopt($ch, CURLOPT_HTTPHEADER, array('Content-Type: application/json; charset=utf-8'));
    // JSON_UNESCAPED_SLASHES deixa as barras das URLs legiveis no envio.
    curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($corpo, JSON_UNESCAPED_SLASHES));
    curl_setopt($ch, CURLOPT_TIMEOUT, 30);

    $texto = curl_exec($ch);

    // curl_exec devolve false quando nem chegou a falar com o servidor.
    if ($texto === false) {
        $erro = curl_error($ch);
        curl_close($ch);
        throw new Exception('Nao consegui falar com a API: ' . $erro);
    }

    $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);

    return array('status' => $status, 'texto' => $texto);
}

function main($host, $chave)
{
    $urls = array(
        'https://' . $host . '/meu-artigo-novo/',
        'https://' . $host . '/uma-pagina-que-mudou/',
    );

    try {
        $r = enviarUrls($host, $chave, $urls);
        echo 'HTTP ' . $r['status'] . "\n";

        if ($r['status'] == 200 || $r['status'] == 202) {
            echo 'Enviado. ' . count($urls) . " URLs na fila.\n";
        } else {
            echo 'A API recusou. Resposta: ' . $r['texto'] . "\n";
        }
    } catch (Exception $e) {
        echo $e->getMessage() . "\n";
    }
}

main($HOST, $CHAVE);

Duas linhas aqui merecem explicação, porque cada uma evita um erro específico.

A primeira é o CURLOPT_RETURNTRANSFER. Sem ele, o curl_exec imprime a resposta na tela em vez de devolver, e a sua variável $texto recebe apenas true. Aí o seu tratamento de erro nunca consegue mostrar a mensagem da API, porque ele não tem a mensagem em mãos.

A segunda é aquele if ($texto === false), com três sinais de igual. Isso não é preciosismo: quando dá tudo certo, a API responde com o corpo vazio, e string vazia no PHP é considerada falsa numa comparação normal. Com ==, o sucesso cairia dentro do bloco de erro e o script anunciaria uma falha que não existiu. O === compara o tipo junto, então só o false de verdade (a falha de conexão) entra ali.

A saída, rodando com php enviar.php:

HTTP 202
Enviado. 2 URLs na fila.

🚨 Os erros que a API devolve (e o que cada um quer dizer)

Esta é a seção que eu queria ter achado pronta quando comecei, porque as mensagens do IndexNow são curtas e algumas chegam sem nenhum texto. Vamos aos casos.

URL de um domínio que não é o seu. Se a urlList tiver endereço de outro site, a API recusa na hora com 422:

{
    "errorCode": "InvalidRequestParameters",
    "message": "One or more URLs are not related to your verified domain. Please verify URLs before submitting.",
    "details": null
}

Vale para o caso óbvio (misturar dois sites no mesmo pedido) e para um bem menos óbvio: www e sem www contam como hosts diferentes. Se o seu host diz exemplo.com.br e a URL da lista está escrita como https://www.exemplo.com.br/..., é este o erro que você recebe.

Chave fora das regras. Chave curta demais, ou com símbolo que não é permitido, também dá 422, e essa mensagem é generosa: ela lista a regra inteira.

{
    "errorCode": "InvalidRequestParameters",
    "message": "Given key is not valid. Your key should have a minimum of 8 and a maximum of 128 alphanumeric characters. The key can contain only the following characters: lowercase characters (a-z), uppercase characters (A-Z), numbers (0-9), and dashes (-).",
    "details": null
}

JSON malformado. Se o corpo não for um JSON válido, aí é 400, com uma mensagem bem mais seca:

{
    "errorCode": "InvalidRequestParameters",
    "message": "Given request parameters are null or invalid",
    "details": null
}

Cabeçalho faltando. E o pior de todos: sem o Content-Type, a resposta é 415 e o corpo vem completamente vazio. Nenhuma pista. É por isso que insisti tanto nele lá em cima.

🤨 O 202 que engana: silêncio não é aprovação

Se você só for ler um pedaço deste artigo, leia este. 🙏

É natural olhar para o 202 e entender "deu tudo certo, minhas URLs foram aceitas". Não é isso que ele significa. O 202 quer dizer apenas "recebi o seu pedido, vou olhar depois". É um recibo de entrega, não um atestado de aprovação.

E a diferença dói na prática, porque estas três situações devolvem 202 alegremente:

  • Um host de um domínio que nem existe.
  • Uma chave que nunca foi publicada no site, ou que está no arquivo errado.
  • Uma urlList vazia, sem nenhuma URL dentro.

Nos três casos a resposta é 202, corpo vazio, e o seu script imprime um lindo "Enviado" na tela. 😳

O motivo é que a validação da chave não acontece durante a sua requisição. O buscador aceita o pedido, coloca na fila, e só mais tarde vai até o seu site buscar aquele .txt para conferir se a chave confere. Se não conferir, ele descarta tudo em silêncio, e ninguém te avisa.

É exatamente por isso que a conferência manual do arquivo, lá na seção da verificação, não é passo opcional. Ela é a única confirmação que você vai ter, porque o 202 não confirma nada. Abra a URL do .txt no navegador e veja a chave com os próprios olhos, uma vez, antes de confiar no resto.

🔁 Um envio basta: eles conversam entre si

Uma dúvida que aparece sempre: preciso avisar o Bing e depois avisar o Yandex separadamente?

Não precisa. Os buscadores que participam do IndexNow compartilham entre si o que recebem. Mandando para api.indexnow.org, o aviso chega em todos eles.

Os endereços individuais existem e funcionam. Tanto https://www.bing.com/indexnow quanto https://yandex.com/indexnow aceitam exatamente o mesmo corpo JSON e devolvem o mesmo 202. Mas mandar para os três é fazer o mesmo trabalho três vezes: escolha um endereço, de preferência o api.indexnow.org, que é o neutro, e siga a vida.

🧩 Encaixando no seu fluxo

A última peça é decidir quando chamar isso. E a resposta curta é: no mesmo lugar onde você já considera o conteúdo publicado.

Se você tem um painel, chame o envio logo depois de salvar o post, com a URL daquele post só. Se o seu site é estático e você faz deploy por script, ponha a chamada no fim do deploy, com a lista do que mudou naquele deploy. Se você publica na mão, deixe o script numa pasta e rode depois de subir o arquivo.

O que não vale a pena é montar uma fila, um sistema de retentativa e um painel de acompanhamento para isso. É um POST que ou vai ou não vai, e se não for hoje, vai no próximo artigo que você publicar. Guarde a sua energia de engenharia para outra coisa. 😉

E, honestamente, o melhor de tudo é o que este protocolo não tem. Sem OAuth, sem token que expira, sem biblioteca para atualizar, sem cadastro para renovar. Um arquivo de texto e uma requisição. Num mundo de integrações que quebram sozinhas a cada seis meses, isso é quase um descanso. 🌈

Por hoje é só, meus unicórnios! 🦄✨

Que a magia do arco-íris continue brilhando em suas vidas! Até mais! 🌈🌟

Perguntas frequentes

O Google usa o IndexNow?
Não. Quem participa é o Bing, o Yandex, o Seznam, o Naver e o buscador da DuckDuckGo (que se apoia no Bing). O Google chegou a dizer que estava testando o protocolo, mas nunca passou a usá-lo. Para o Google, o caminho continua sendo o sitemap e o Search Console. Isso não torna o IndexNow inútil: são dez minutos de trabalho uma vez só, e o resto do mundo dos buscadores passa a saber das suas mudanças na hora.
Como eu crio a chave do IndexNow?
Você mesmo inventa a chave, não existe cadastro nem painel. Ela precisa ter de 8 a 128 caracteres, usando só letras minúsculas, maiúsculas, números e traço. O jeito mais simples é gerar 32 caracteres hexadecimais aleatórios: crypto.randomBytes(16).toString("hex") no Node ou bin2hex(random_bytes(16)) no PHP. Depois é só publicar um arquivo de texto na raiz do site cujo nome é a chave e cujo conteúdo é a mesma chave.
Por que a API responde 202 mesmo quando eu erro alguma coisa?
Porque o 202 significa só "recebi o seu pedido e vou olhar depois", não "está tudo certo". Mandando um host que não existe, uma chave que nunca foi publicada ou uma urlList vazia, a resposta é 202 do mesmo jeito, com o corpo vazio. A validação da chave acontece depois, quando o buscador vai buscar o seu arquivo .txt. Os erros que ele recusa na hora são outros: URL de domínio diferente, chave malformada e falta do cabeçalho de tipo.
Por que dá erro 415 ao enviar com curl?
Falta o cabeçalho Content-Type: application/json; charset=utf-8. A API responde 415 com o corpo vazio, sem nenhuma mensagem explicando, então é um erro que trava bastante gente. No fetch do Node e no curl_setopt do PHP esse cabeçalho tem de ser escrito à mão: nenhum dos dois manda JSON por conta própria.
Posso mandar URLs de vários sites no mesmo pedido?
Não. Todas as URLs da urlList precisam ser do mesmo host que você declarou no campo host. Misturando domínios, a API recusa na hora com 422 e a mensagem One or more URLs are not related to your verified domain. Para vários sites, um pedido para cada.
Preciso enviar para o Bing e para o Yandex separadamente?
Não precisa. Os buscadores que participam compartilham o que recebem entre si, então um envio para api.indexnow.org basta. Os endereços www.bing.com/indexnow e yandex.com/indexnow aceitam o mesmo pedido e devolvem o mesmo 202, mas mandar para os três é repetir trabalho à toa.

Leia também