DM automática por comentário no Instagram
Alguém comenta uma palavra-chave no seu post. Essa pessoa recebe uma DM, e o comentário ganha uma resposta pública. O Supabase recebe o webhook da Meta. O n8n, rodando na sua máquina, lê a fila, envia a mensagem e responde o comentário.
O uso é na sua própria conta profissional. Não precisa de análise do app nem de verificação do negócio. Para atender conta de cliente, a análise passa a ser obrigatória.
0. Chaves que você vai juntar
Nenhum desses valores entra no Git, em print ou em repositório público.
| Chave | Onde pegar | Onde usar |
|---|---|---|
| SUPABASE_URL | Supabase → Project Settings → API | n8n, no nó Config |
| SUPABASE_SECRET_KEY | Project Settings → API Keys. sb_secret_... ou a chave legacy service_role | Credencial do n8n |
| PROJECT_REF | Project Settings → General → Reference ID | CLI do Supabase |
| META_APP_SECRET | Meta → app → Instagram → Configuração da API com login do Instagram → Instagram App Secret | Secret da Edge Function |
| META_VERIFY_TOKEN | Você gera no passo 1 | Secret da Edge Function e painel da Meta |
| IG_ACCESS_TOKEN | A mesma página da Meta → Gerar tokens de acesso | Tabela ig_settings |
| IG_USER_ID | Passo 3.7 | Secret da Edge Function e n8n |
| GRAPH_VERSION | v26.0 | n8n e comandos |
| N8N_ENCRYPTION_KEY | Você gera no passo 1 | docker-compose.yml |
1. Pré-requisitos
- Docker Desktop.
- Node.js 18 ou mais novo.
- Conta do Instagram profissional, comercial ou criador. Instagram → Configurações → Tipo de conta e ferramentas → Mudar para conta profissional.
- Uma segunda conta do Instagram para comentar nos testes.
Gere dois valores aleatórios. Um vira META_VERIFY_TOKEN. O outro vira N8N_ENCRYPTION_KEY. Rode o comando duas vezes:
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"2. Supabase
2.1 Projeto
Crie um projeto em supabase.com e anote SUPABASE_URL, SUPABASE_SECRET_KEY e PROJECT_REF.
2.2 Tabelas e funções
No SQL Editor, New query, rode o bloco inteiro. Ele cria a fila, as regras, a tabela do token e as funções que o n8n chama. A função de reserva usa trava de linha para dois processos não pegarem o mesmo comentário.
create table if not exists public.ig_keyword_rules (
id bigint generated always as identity primary key,
keyword text not null,
match_mode text not null default 'word' check (match_mode in ('word','exact')),
media_id text,
message_text text not null,
priority int not null default 100,
active boolean not null default true,
created_at timestamptz not null default now()
);
create table if not exists public.ig_comment_queue (
id bigint generated always as identity primary key,
comment_id text not null unique,
ig_account_id text not null,
commenter_id text,
username text,
comment_text text,
media_id text,
rule_id bigint references public.ig_keyword_rules(id) on delete set null,
message_text text not null,
status text not null default 'pending'
check (status in ('pending','processing','sent','skipped','failed')),
attempts int not null default 0,
max_attempts int not null default 4,
send_after timestamptz not null default now(),
locked_at timestamptz,
comment_created_at timestamptz not null,
sent_message_id text,
last_error text,
created_at timestamptz not null default now(),
processed_at timestamptz,
updated_at timestamptz not null default now()
);
create index if not exists ig_queue_pending_idx
on public.ig_comment_queue (send_after) where status = 'pending';
create index if not exists ig_queue_processing_idx
on public.ig_comment_queue (locked_at) where status = 'processing';
create table if not exists public.ig_settings (
key text primary key,
value text not null,
updated_at timestamptz not null default now()
);
alter table public.ig_keyword_rules enable row level security;
alter table public.ig_comment_queue enable row level security;
alter table public.ig_settings enable row level security;
create or replace function public.claim_ig_comments(batch_size int default 5)
returns setof public.ig_comment_queue
language sql
as $$
update public.ig_comment_queue q
set status = 'processing', attempts = q.attempts + 1,
locked_at = now(), updated_at = now()
where q.id in (
select id from public.ig_comment_queue
where status = 'pending' and send_after <= now() and attempts < max_attempts
order by send_after
limit batch_size
for update skip locked
)
returning q.*;
$$;
create or replace function public.maintain_ig_queue(window_hours int default 160, stuck_minutes int default 5)
returns jsonb
language plpgsql
as $$
declare n_expired int; n_reaped int;
begin
update public.ig_comment_queue
set status = 'failed', last_error = 'janela da Private Reply expirada', updated_at = now()
where status = 'pending'
and comment_created_at < now() - make_interval(hours => window_hours);
get diagnostics n_expired = row_count;
update public.ig_comment_queue
set status = case when attempts >= max_attempts then 'failed' else 'pending' end,
last_error = 'travado em processing; liberado', locked_at = null, updated_at = now()
where status = 'processing'
and locked_at < now() - make_interval(mins => stuck_minutes);
get diagnostics n_reaped = row_count;
return jsonb_build_object('expired', n_expired, 'reaped', n_reaped);
end;
$$;
create or replace function public.skip_ig_comment(cid text)
returns boolean
language sql
as $$
with upd as (
update public.ig_comment_queue
set status = 'skipped', updated_at = now()
where comment_id = cid and status = 'pending'
returning 1
)
select exists (select 1 from upd);
$$;
revoke execute on function public.claim_ig_comments(int) from public, anon, authenticated;
revoke execute on function public.maintain_ig_queue(int, int) from public, anon, authenticated;
revoke execute on function public.skip_ig_comment(text) from public, anon, authenticated;2.3 Limpeza automática
Opcional. Apaga da fila o que já foi enviado, pulado ou falhou, depois de 90 dias.
create extension if not exists pg_cron;
select cron.schedule('ig-cleanup', '7 4 * * *', $$
delete from public.ig_comment_queue
where status in ('sent','skipped','failed')
and updated_at < now() - interval '90 days';
$$);2.4 Regras de palavra-chave
Cada palavra vira uma linha. message_text é o texto da DM. media_id vazio vale para qualquer post. Preenchido, vale só para aquele vídeo e ganha da regra global.
-- Qualquer post
insert into public.ig_keyword_rules (keyword, message_text)
values ('quero', 'Oi! Aqui está o link: https://seu-link');
-- Só um post específico. Ganha da regra global.
-- insert into public.ig_keyword_rules (keyword, media_id, message_text, priority)
-- values ('ebook', 'ID_DA_MIDIA', 'Aqui está o ebook: https://...', 10);word, o padrão: a palavra aparece inteira.Eu QUERO!casa.queroooonão casa.não querocasa.exact: o comentário inteiro é só a palavra.- Entre duas regras globais que casam, vence a de
prioritymenor. Regra commedia_idvence a global.
3. Meta
3.1 Conta de desenvolvedor
Em developers.facebook.com, entre com a sua conta Meta. Começar. Confirme e-mail e telefone.
3.2 Criar o app
- Meus apps → Criar app.
- Nome e e-mail de contato.
- Caso de uso: Gerenciar mensagens e conteúdo no Instagram.
- Portfólio empresarial: pode pular.
- Criar app.
3.3 Instagram App Secret
- Casos de uso → Personalizar, no Instagram → Configuração da API com login do Instagram.
- Instagram App Secret → Mostrar → copie. Esse valor é o
META_APP_SECRET.
3.4 Testadores
- Funções do app → Funções → Adicionar pessoas → Testador do Instagram.
- Adicione a sua conta profissional e a conta de teste.
- Em cada conta, aceite em instagram.com → Configurações → Apps e sites → Convites de testador.
3.5 Liberar mensagens na conta profissional
No app do Instagram: Configurações → Mensagens e respostas a stories → Controles de mensagens → Ferramentas conectadas → permitir acesso.
3.6 Gerar o token
- Configuração da API com login do Instagram → Gerar tokens de acesso → Adicionar conta.
- Entre com a conta profissional no pop-up e autorize tudo.
- Gerar token ao lado da conta. Esse valor é o
IG_ACCESS_TOKEN. Vale 60 dias. O n8n renova.
3.7 Descobrir o IG_USER_ID
Troque IG_ACCESS_TOKEN pelo token gerado. Use o campo user_id da resposta.
curl.exe "https://graph.instagram.com/v26.0/me?fields=user_id,username&access_token=IG_ACCESS_TOKEN"3.8 Inscrever a conta no campo comments
Assinar o campo no painel não inscreve a conta. Sem este passo, nenhum comentário real chega. O POST substitui a lista inteira, então mande comments e messages juntos.
curl.exe -X POST "https://graph.instagram.com/v26.0/me/subscribed_apps?subscribed_fields=comments,messages&access_token=IG_ACCESS_TOKEN"O esperado é {"success":true}. Confira:
curl.exe "https://graph.instagram.com/v26.0/me/subscribed_apps?access_token=IG_ACCESS_TOKEN"A resposta precisa listar comments e messages.
3.9 Publicar o app
Publicar tira o app do modo de desenvolvimento. Esse modo bloqueia os eventos reais. Não é a análise do app: não pede CNPJ, verificação nem vídeo.
- Crie uma página pública simples, por exemplo na Vercel, com a política de privacidade e as instruções de exclusão de dados.
- Configurações do app → Básico: URL da política, URL de exclusão de dados, categoria e ícone de 1024×1024. Salve.
- No menu lateral, Publicar. No painel antigo, a chave é Modo do app → Live.
Este app processa comentários da conta @suaconta para enviar respostas automáticas por mensagem. Os dados não são compartilhados com terceiros. Para excluir seus dados, envie e-mail para seu@email.com.
4. Edge Function ig-webhook
4.1 CLI
Em uma pasta nova:
npx supabase login
npx supabase init
npx supabase link --project-ref PROJECT_REF
npx supabase functions new ig-webhook4.2 Código
Substitua supabase/functions/ig-webhook/index.ts por este arquivo. Ele confere a assinatura da Meta, ignora o seu próprio comentário, escolhe a regra e grava a fila. Comentário repetido não entra de novo.
import { createClient } from "npm:@supabase/supabase-js@2";
const supabase = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,
);
const VERIFY_TOKEN = Deno.env.get("META_VERIFY_TOKEN")!;
const APP_SECRET = Deno.env.get("META_APP_SECRET")!;
const OWN_IG_USER_ID = Deno.env.get("OWN_IG_USER_ID") ?? "";
const JITTER_SECONDS = Number(Deno.env.get("SEND_JITTER_SECONDS") ?? "20");
const hmacKey = crypto.subtle.importKey(
"raw", new TextEncoder().encode(APP_SECRET),
{ name: "HMAC", hash: "SHA-256" }, false, ["verify"],
);
function hexToBytes(hex: string): Uint8Array | null {
if (!/^[0-9a-f]{64}$/i.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 validSignature(raw: Uint8Array, header: string | null): Promise<boolean> {
if (!header?.startsWith("sha256=")) return false;
const sig = hexToBytes(header.slice(7));
if (!sig) return false;
return crypto.subtle.verify("HMAC", await hmacKey, sig, raw);
}
function normalize(s: string): string {
return s.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase()
.replace(/[^\p{L}\p{N}\s]/gu, " ").replace(/\s+/g, " ").trim();
}
type Rule = {
id: number; keyword: string; match_mode: "word" | "exact";
media_id: string | null; message_text: string; priority: number;
};
function matches(text: string, rule: Rule): boolean {
const kw = normalize(rule.keyword);
if (!kw) return false;
if (rule.match_mode === "exact") return text === kw;
return ` ${text} `.includes(` ${kw} `);
}
function pickRule(rules: Rule[], text: string, mediaId: string | null): Rule | null {
return rules
.filter((r) => r.media_id === null || r.media_id === mediaId)
.filter((r) => matches(text, r))
.sort((a, b) => {
const spec = Number(b.media_id !== null) - Number(a.media_id !== null);
return spec !== 0 ? spec : a.priority - b.priority;
})[0] ?? null;
}
function eventDate(time: unknown): Date {
const n = Number(time);
if (!Number.isFinite(n) || n <= 0) return new Date();
return new Date(n > 1e12 ? n : n * 1000);
}
Deno.serve(async (req) => {
if (req.method === "GET") {
const url = new URL(req.url);
if (url.searchParams.get("hub.mode") === "subscribe" &&
url.searchParams.get("hub.verify_token") === VERIFY_TOKEN) {
return new Response(url.searchParams.get("hub.challenge") ?? "", { status: 200 });
}
return new Response("forbidden", { status: 403 });
}
if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
const raw = new Uint8Array(await req.arrayBuffer());
if (!(await validSignature(raw, req.headers.get("x-hub-signature-256")))) {
return new Response("invalid signature", { status: 401 });
}
let body: any;
try { body = JSON.parse(new TextDecoder().decode(raw)); }
catch { return new Response("bad json", { status: 400 }); }
const { data: rules, error: rulesErr } = await supabase
.from("ig_keyword_rules")
.select("id, keyword, match_mode, media_id, message_text, priority")
.eq("active", true);
if (rulesErr) return new Response("db error", { status: 500 });
const rows: Record<string, unknown>[] = [];
for (const entry of body.entry ?? []) {
const accountId = String(entry.id ?? "");
const createdAt = eventDate(entry.time);
for (const change of entry.changes ?? []) {
if (change.field !== "comments") continue;
const v = change.value ?? {};
const commentId = v.id ? String(v.id) : "";
const fromId = String(v.from?.id ?? "");
if (!commentId || !fromId) continue;
if (fromId === accountId || fromId === OWN_IG_USER_ID) continue;
const mediaId = v.media?.id ? String(v.media.id) : null;
const rule = pickRule((rules ?? []) as Rule[], normalize(String(v.text ?? "")), mediaId);
if (!rule) continue;
rows.push({
comment_id: commentId,
ig_account_id: accountId,
commenter_id: fromId,
username: v.from?.username ?? null,
comment_text: v.text ?? null,
media_id: mediaId,
rule_id: rule.id,
message_text: rule.message_text,
comment_created_at: createdAt.toISOString(),
send_after: new Date(Date.now() + Math.random() * JITTER_SECONDS * 1000).toISOString(),
});
}
}
if (rows.length > 0) {
const { error } = await supabase
.from("ig_comment_queue")
.upsert(rows, { onConflict: "comment_id", ignoreDuplicates: true });
if (error) return new Response("db error", { status: 500 });
}
return new Response("ok", { status: 200 });
});4.3 Secrets e deploy
No PowerShell a barra no fim da linha não continua o comando. Troque valor pelos valores reais, tudo em uma linha. SUPABASE_URL e SUPABASE_SERVICE_ROLE_KEY já existem na função. Não defina de novo. Se mudar um secret depois, publique outra vez.
npx supabase secrets set META_VERIFY_TOKEN="valor" META_APP_SECRET="valor" OWN_IG_USER_ID="valor" SEND_JITTER_SECONDS="20"
npx supabase secrets list
npx supabase functions deploy ig-webhook --no-verify-jwt4.4 Testar a verificação
curl.exe "https://PROJECT_REF.supabase.co/functions/v1/ig-webhook?hub.mode=subscribe&hub.verify_token=META_VERIFY_TOKEN&hub.challenge=12345"O esperado é 12345. Se vier 403, o token na URL não é o mesmo secret.
4.5 Registrar o webhook na Meta
- Configuração da API com login do Instagram → Configurar webhooks.
- URL de callback:
https://PROJECT_REF.supabase.co/functions/v1/ig-webhook. - Token de verificação: o
META_VERIFY_TOKEN. - Verificar e salvar.
- Na linha
comments, ligue Assinar. Testar não basta. - Na seção de tokens, ligue a assinatura de webhook ao lado da sua conta.
5. n8n local
5.1 Docker
Crie a pasta n8n e o arquivo docker-compose.yml. Na linha N8N_ENCRYPTION_KEY, o valor depois do igual é o hex gerado no passo 1. Não troque essa chave depois de criar credenciais. No Docker Desktop, Settings → General → Start Docker Desktop when you sign in.
services:
n8n:
image: docker.n8n.io/n8nio/n8n
restart: unless-stopped
ports:
- "5678:5678"
environment:
- N8N_ENCRYPTION_KEY=N8N_ENCRYPTION_KEY
- GENERIC_TIMEZONE=America/Fortaleza
- TZ=America/Fortaleza
- N8N_SECURE_COOKIE=false
- EXECUTIONS_DATA_SAVE_ON_SUCCESS=none
- EXECUTIONS_DATA_PRUNE=true
- EXECUTIONS_DATA_MAX_AGE=72
volumes:
- n8n_data:/home/node/.n8n
volumes:
n8n_data:docker compose up -dAbra http://localhost:5678 e crie o usuário.
5.2 Credencial do Supabase
Credentials → Add credential → Custom Auth. Nome: Supabase Service.
Chave que começa com sb_secret_ :
{ "headers": { "apikey": "SUPABASE_SECRET_KEY" } }Chave legacy service_role, a que começa com eyJ:
{ "headers": { "apikey": "SUPABASE_SECRET_KEY", "Authorization": "Bearer SUPABASE_SECRET_KEY" } }5.3 Workflow IG - Enviar DMs
Ligue os nós nesta ordem. O IF abre dois caminhos: todo resultado atualiza a fila, e só o envio certo segue para a resposta pública.
Schedule Trigger → Config → Manutenção → Buscar token → Reservar lote → Enviar Private Reply → Classificar resultado
├→ Atualizar fila
└→ IF (sent) → Gerar resposta → Responder comentárioSchedule Trigger. Trigger Interval: Seconds. Seconds Between Triggers: 20.
Config. Edit Fields, Set, Manual Mapping, todos String. supabase_url é a SUPABASE_URL sem barra no fim. graph_base é https://graph.instagram.com. graph_version é v26.0. ig_user_id é o IG_USER_ID.
Manutenção. HTTP Request, POST. URL:
{{ $('Config').first().json.supabase_url }}/rest/v1/rpc/maintain_ig_queueAuthentication: Generic Credential Type → Custom Auth → Supabase Service. Send Body, JSON: { "window_hours": 160, "stuck_minutes": 5 }.
Buscar token. HTTP Request em GET, sem corpo. Se este nó ficar POST com corpo, o Supabase procura uma coluna que não existe.
{{ $('Config').first().json.supabase_url }}/rest/v1/ig_settings?key=eq.ig_access_token&select=valueReservar lote. HTTP Request, POST. Always Output Data desligado.
{{ $('Config').first().json.supabase_url }}/rest/v1/rpc/claim_ig_commentsCorpo JSON: { "batch_size": 3 }.
Enviar Private Reply. HTTP Request, POST. Authentication: None. Header Authorization com o valor Bearer {{ $('Buscar token').first().json.value }}. Include Response Headers and Status ligado. Never Error ligado. Batching: 1 item por lote, intervalo de 2000 ms.
{{ $('Config').first().json.graph_base }}/{{ $('Config').first().json.graph_version }}/{{ $('Config').first().json.ig_user_id }}/messagesCorpo em modo expressão:
{{ JSON.stringify({ recipient: { comment_id: $json.comment_id }, message: { text: $json.message_text } }) }}Classificar resultado. Code, Run Once for Each Item.
const job = $('Reservar lote').item.json;
const res = $json;
const status = Number(res.statusCode ?? 0);
const body = res.body ?? {};
const now = new Date().toISOString();
const TRANSIENT = [1, 2, 4, 17, 32, 341, 613];
const AUTH = [102, 190];
if (status >= 200 && status < 300) {
return { json: { id: job.id, comment_id: job.comment_id, username: job.username, outcome: 'sent', patch: {
status: 'sent', sent_message_id: body.message_id ?? null,
processed_at: now, locked_at: null, last_error: null, updated_at: now,
} } };
}
const err = body.error ?? {};
const code = Number(err.code);
const errText = JSON.stringify(err).slice(0, 1000);
if (AUTH.includes(code)) {
return { json: { id: job.id, outcome: 'auth_error', patch: {
status: 'pending', attempts: Math.max(job.attempts - 1, 0),
locked_at: null, last_error: 'auth: ' + errText, updated_at: now,
} } };
}
const transient = err.is_transient === true || TRANSIENT.includes(code) || status === 0 || status >= 500;
const exhausted = job.attempts >= job.max_attempts;
if (transient && !exhausted) {
const backoffMin = Math.min(2 ** job.attempts, 30);
return { json: { id: job.id, outcome: 'retry', patch: {
status: 'pending', send_after: new Date(Date.now() + backoffMin * 60000).toISOString(),
locked_at: null, last_error: errText, updated_at: now,
} } };
}
return { json: { id: job.id, outcome: 'failed', patch: {
status: 'failed', locked_at: null, last_error: errText, processed_at: now, updated_at: now,
} } };Atualizar fila. HTTP Request, PATCH. Header Prefer = return=minimal. Corpo em expressão: {{ JSON.stringify($json.patch) }}.
{{ $('Config').first().json.supabase_url }}/rest/v1/ig_comment_queue?id=eq.{{ $json.id }}Enviada? IF. {{ $json.outcome }} é igual a sent.
Gerar resposta. Code, Run Once for Each Item, na saída verdadeira do IF. A frase pública muda a cada comentário.
const aberturas = ["", "Oi! ", "Opa! ", "Prontinho! ", "Feito! ", "Olá! ", "Show! ", "Perfeito! ", "Beleza! ", "Pronto! "];
const frases = [
"te mandei na DM", "enviei no seu direct", "acabei de mandar no privado", "olha seu direct",
"corre lá na DM", "mandei tudo no direct", "dá uma olhada na DM", "te chamei no privado",
"já está no seu direct", "confere a DM",
];
const finais = ["", "!", " 😉", " 📩", " 🚀", " 🙌", "! Qualquer dúvida é só chamar", ". Se não aparecer, olha em Solicitações"];
const pick = (arr) => arr[Math.floor(Math.random() * arr.length)];
let abertura = pick(aberturas);
let frase = pick(frases);
const final = pick(finais);
const username = $json.username;
if (username && Math.random() < 0.5) abertura = `@${username} `;
if (!abertura) frase = frase.charAt(0).toUpperCase() + frase.slice(1);
return { json: { ...$json, reply_text: `${abertura}${frase}${final}` } };Responder comentário. HTTP Request, POST. Never Error ligado. O comment_id sai do nó Reservar lote. Se a URL usar um id vazio, a API responde que o objeto replies não existe.
{{ $('Config').first().json.graph_base }}/{{ $('Config').first().json.graph_version }}/{{ $('Reservar lote').item.json.comment_id }}/repliesHeader Authorization: Bearer {{ $('Buscar token').first().json.value }}. Corpo em expressão: {{ JSON.stringify({ message: $json.reply_text }) }}.
Salve e ative o workflow. A chave se chama Active, ou o botão Publish, conforme a versão. O botão Execute workflow só roda uma vez.
5.4 Workflow IG - Renovar token
Schedule Trigger semanal, segunda, 3h. Config com supabase_url igual à SUPABASE_URL.
Buscar token, GET, Custom Auth Supabase Service:
{{ $('Config').first().json.supabase_url }}/rest/v1/ig_settings?key=eq.ig_access_token&select=valueRenovar, GET https://graph.instagram.com/refresh_access_token. Query: grant_type = ig_refresh_token, access_token = {{ $json.value }}.
Salvar token, PATCH. Header Prefer = return=minimal. Corpo em expressão: {{ JSON.stringify({ value: $json.access_token, updated_at: new Date().toISOString() }) }}.
{{ $('Config').first().json.supabase_url }}/rest/v1/ig_settings?key=eq.ig_access_tokenSalve e ative.
5.5 Gravar o token no banco
Troque IG_ACCESS_TOKEN pelo token do passo 3.6. Este é o único lugar em que o token fica. Não cole em código, em print ou no Git.
insert into public.ig_settings (key, value)
values ('ig_access_token', 'IG_ACCESS_TOKEN')
on conflict (key) do update set value = excluded.value, updated_at = now();6. Testes
6.1 Token
curl.exe "https://graph.instagram.com/v26.0/me?fields=user_id,username&access_token=IG_ACCESS_TOKEN"6.2 Webhook com evento falso
Arquivo teste-webhook.mjs. Troque os dois placeholders pelos valores reais.
import crypto from "node:crypto";
const APP_SECRET = "META_APP_SECRET";
const URL = "https://PROJECT_REF.supabase.co/functions/v1/ig-webhook";
const now = Math.floor(Date.now() / 1000);
const body = JSON.stringify({
object: "instagram",
entry: [{ id: "111", time: now, changes: [{ field: "comments", value: {
id: `teste_${now}`, text: "Eu QUERO!", from: { id: "999", username: "teste" }, media: { id: "media_1" },
} }] }],
});
const sig = crypto.createHmac("sha256", APP_SECRET).update(body).digest("hex");
const res = await fetch(URL, {
method: "POST",
headers: { "Content-Type": "application/json", "X-Hub-Signature-256": `sha256=${sig}` },
body,
});
console.log(res.status, await res.text());node teste-webhook.mjsO esperado é 200 ok e uma linha pending:
select comment_id, status, send_after from public.ig_comment_queue order by id desc limit 5;6.3 n8n
Espere cerca de 20 segundos e rode Execute workflow uma vez. A linha de teste deve virar failed, com erro de parâmetro, porque o comentário é falso. Isso valida a credencial, a reserva, a chamada à API e a atualização da fila.
6.4 Teste real
- Com outra conta, não a profissional, comente a palavra-chave em um post.
- Supabase → Edge Functions → ig-webhook → Invocations: deve aparecer uma chamada 200.
- A linha nasce pending e, em menos de um minuto, vira sent.
- A DM chega na conta que comentou. Se ela não segue você, a mensagem cai em Solicitações. O comentário recebe a resposta pública.
7. Erros que já apareceram
| Erro | Causa | Solução |
|---|---|---|
| curl do PowerShell dá erro vermelho | curl é apelido do Invoke-WebRequest | Use curl.exe |
| openssl não existe | Windows | Use o Node do passo 1 e o script do 6.2 |
| Comando com barra no fim quebra | PowerShell não continua linha com barra | Escreva em uma linha |
| 403 no teste de verificação | A URL ainda tem o texto META_VERIFY_TOKEN | Use o valor real |
| Could not find the stuck_minutes column of ig_settings | Buscar token ficou POST, copiado da Manutenção | Buscar token em GET, sem corpo |
| Workflow só roda no botão | Workflow não ativado | Ligar Active ou Publish |
| Teste da Meta chega, comentário real não | Conta inscrita só em messages | Passo 3.8 |
| Nenhum evento real chega | App em modo de desenvolvimento | Passo 3.9 |
| Object with ID replies does not exist | comment_id vazio na URL | Use $('Reservar lote').item.json.comment_id |
| 401 no ig-webhook | Segredo errado | Use o Instagram App Secret do passo 3.3 |
| last_error começa com auth: | Token inválido ou expirado | Gere outro no passo 3.6 e regrave no 5.5 |
8. Consultas úteis
-- Situação da fila
select status, count(*) from public.ig_comment_queue group by status;
-- Pendentes atrasados. n8n parado ou token quebrado.
select count(*) from public.ig_comment_queue
where status = 'pending' and send_after < now() - interval '10 minutes';
-- Falhas
select comment_id, username, last_error, updated_at
from public.ig_comment_queue where status = 'failed'
order by updated_at desc limit 20;
-- Pular um comentário que você respondeu à mão
select public.skip_ig_comment('ID_DO_COMENTARIO');
-- Desativar regra
update public.ig_keyword_rules set active = false where id = 1;9. Limites
- A DM, a Private Reply, só pode ser enviada até 7 dias depois do comentário, uma vez por comentário.
- Depois disso, outra mensagem só existe se a pessoa responder, e dentro de 24 horas dessa resposta.
- Se a pessoa não segue a conta, a DM vai para Solicitações.
- O envio só acontece com o PC e o Docker ligados. O que chegar com o PC desligado sai quando ele voltar, dentro dos 7 dias.
- A API não permite curtir comentários.
- Uso só na sua conta. Para clientes, a Meta exige a análise do app com verificação do negócio.
