Contrato de feedback
Um pequeno diálogo que as pessoas podem abrir em qualquer página do seu site ou produto para dizer o que não funciona ou o que falta. Cole uma tag de script ou chame o endpoint a partir do seu próprio diálogo. O que chega aparece em um único bloco do painel, ao lado da pesquisa de desinstalação e do Rate Us, e o proprietário recebe um aviso na caixa de entrada.
1. Cole o widget
Uma única tag script adiada, sem código inline, por isso funciona com uma CSP rígida:
<script src="https://extops.dev/widget.js"
data-extops-widget="wk_XXXXXXXXXXXXXXXXXXXXXXXXXXXX" defer></script>
Permita a nossa origem em script-src e connect-src. O arquivo é servido por nós, não tem dependências e fica abaixo de 7 KB gzip; os estilos passam pelo CSSOM dentro de um shadow root, então você não precisa de 'unsafe-inline' e o CSS da sua página não consegue deformar o diálogo.
Atributos opcionais: data-locale, data-api, data-position="left" e data-button="false" quando você quiser abri-lo por conta própria com window.ExtOpsFeedback.open(). Também: data-button-style="icon" para um botão redondo de ícone de 48 px, data-surface="app" para posicioná-lo acima da navegação inferior de um app no celular, e data-color-preset para definir a cor do botão pela página.
Tema claro e escuro
Cada preset de cor é desenhado em um tema claro e em um escuro. Por padrão (data-theme="auto") o widget segue a sua página: <html data-theme>, uma classe dark ou light em <html>, ou o seu color-scheme. Uma página sem tema próprio recebe o tema do sistema do visitante. Quando o seu seletor de tema muda uma dessas propriedades, o widget é redesenhado na hora, sem recarregar.
Para fixar o tema, defina data-theme="light" ou data-theme="dark". Se a sua página guarda o tema onde o widget não enxerga, chame window.ExtOpsFeedback.setTheme('dark') a partir do seu seletor, e setTheme('auto') para voltar a seguir a página.
2. Ou chame o endpoint você mesmo
Para um site que não permite nenhum script de terceiros, ou um produto que quer o formulário dentro do próprio design system. É o mesmo endpoint para o qual o nosso widget envia, então você nunca precisa escolher entre a sua interface e o nosso pipeline.
await fetch('https://extops.dev/api/v1/feedback/widget', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
widget_key: 'wk_XXXXXXXXXXXXXXXXXXXXXXXXXXXX',
topic: 'bug',
comment: userText,
page: location.href,
locale: navigator.language,
event_id: crypto.randomUUID(),
dwell_ms: Date.now() - openedAt,
}),
});
Leia os tópicos que o dono configurou em GET /api/v1/feedback/widget/config?key=… para que o seu diálogo ofereça exatamente o que o painel oferece. Reutilize o mesmo event_id nas novas tentativas: o servidor deduplica por ele, então uma rede instável não transforma uma mensagem em duas.
3. A chave do widget é pública de propósito
Ela aparece no código-fonte da sua página, e tudo bem: o único poder dela é criar uma linha de feedback. Não consegue gravar eventos de instalação ou desinstalação, não lê nada além da configuração do diálogo e não pode indicar um projeto (project_id no corpo não é aceito). Por isso um widget recebe a própria chave em vez do webhook_token da sua fonte, que pode gravar eventos de métricas e nunca deve aparecer no HTML. Você pode reemitir a chave a qualquer momento; a antiga deixa de ser aceita na hora.
4. O que guardamos e o que não guardamos
Guardamos o tópico, o texto, a página normalizada, o idioma, um contato opcional, o horário, o tipo de feedback e qual widget o coletou.
- Nem a query string nem o fragmento da página. Eles são descartados no recebimento: costumam trazer tokens de acesso, endereços de e-mail e termos de busca que nunca pedimos e não queremos guardar por 180 dias.
- Nem o país nem o IP de quem envia. O país identificado fica apenas no agregado diário, nunca em um evento individual; o IP é só uma chave temporária do limitador de taxa.
- Nenhum contato quando o widget não pede um. Um trecho antigo que ainda o envia não recebe erro: o campo é descartado, porque o texto de privacidade promete o que a configuração atual do dono define.
A retenção é de 180 dias, a mesma regra de qualquer outro tipo de feedback.
5. Respostas e códigos de erro
Um sucesso é assim: {"status":"ok","recorded":true}. recorded: false é também um sucesso: o envio era duplicado ou uma heurística antibot foi acionada. Mostre o mesmo agradecimento nos dois casos: um script não deve saber qual regra o pegou, e quem tenta de novo após um timeout não deve ver erro por uma mensagem que já temos.
| HTTP | code | Quando |
|---|---|---|
| 400 | validation_error | sem chave, sem tópico, um tópico que o widget não oferece, um comentário vazio, um corpo malformado |
| 403 | origin_not_allowed | a origem da página não está na lista de permitidas do widget |
| 404 | not_found | chave desconhecida ou um widget desativado: a mesma resposta, para que uma chave pública não consiga sondar quais projetos existem |
| 429 | rate_limited | 60/hora por chave, 10/hora por IP; traz Retry-After |
| 401 | source_token_invalid | apenas no caminho server-side: token de fonte errado |
| 500 | internal_error | erro nosso |
6. Tópicos, tipos e status
Um tópico é um id mais um label. O id é um slug, porque fica gravado em cada linha e vira o valor de filtro no painel; o rótulo é escrito pelo dono. Um widget sem tópicos configurados oferece os padrões: bug, idea, question, other.
A cor do diálogo é uma de seis predefinições compartilhadas com as páginas hospedadas: amber (o padrão), teal, indigo, violet, rose, slate. Uma lista única de propósito: o widget e as páginas hospedadas do mesmo dono devem se parecer, e uma segunda paleta acabaria se afastando da primeira.
As linhas gravadas têm um tipo: uninstall, rate_us ou widget. O tipo é uma TAG, não um pipeline separado: o bloco do painel, as notificações e a retenção são compartilhados. O status de triagem do dono é new, read ou spam; marcar como spam tira a linha da lista de trabalho e das notificações sem apagar o que a pessoa escreveu, e pode ser desfeito.
7. A proteção contra abuso fica no servidor
Como a chave é pública, uma verificação no navegador seria um conselho, não uma regra. Quem aplica é o servidor: a lista de origens permitidas do widget (vazia significa qualquer origem, porque um cliente pode servir um site por uma dúzia de subdomínios), dois limites de taxa independentes (por chave e por IP), um campo honeypot e um tempo mínimo de permanência, e deduplicação por (widget_id, event_id) no banco em vez de uma verificação prévia, porque duas tentativas simultâneas passariam ambas por um SELECT.
Os dois limitadores falham abertos: se o nosso Redis cair, o feedback de uma pessoa ainda chega a você. Para um endpoint que grava um parágrafo, é o mal menor. Não adicionamos captcha externo, que quebraria sua CSP e a privacidade dos seus visitantes.
8. O contrato legível por máquina
A descrição formal dos dois endpoints de recebimento, da leitura de configuração, dos enums e dos códigos de erro é o arquivo OpenAPI 3.1. Ele traz a mesma versão de contrato (1.0.0).