# Payloads de webhook

URL: /desenvolvedores/webhooks

Configure destinos, valide a assinatura e conheça o payload de cada evento que o Mira envia.

O Mira envia eventos JSON para destinos HTTPS cadastrados por projeto. O receptor não precisa de conta nem de chave de API do Mira: o segredo do destino autentica cada mensagem.

## Configurar um destino [#configurar-um-destino]

**Quem faz:** admin do projeto.

<Steps>
<Step>

### Abrir Webhooks [#abrir-webhooks]

Em **Configurações** do projeto, abra **Integrações** e clique em **Webhooks**. O painel lateral lista os destinos.

</Step>
<Step>

### Adicionar o destino [#adicionar-o-destino]

Clique em **Adicionar destino** e informe nome, URL HTTPS pública e os eventos que o destino recebe.

</Step>
<Step>

### Guardar o segredo [#guardar-o-segredo]

Salve e copie o segredo (`mira_whsec_...`) para a configuração privada do receptor. Ele aparece uma vez e não pode ser consultado depois.

</Step>
<Step>

### Testar [#testar]

Use **Enviar teste** e abra **Histórico de entregas** para ver o resultado. O teste publica `webhook.test` só para esse destino, com `data.report: null`.

</Step>
</Steps>

A URL precisa usar HTTPS com certificado válido. O Mira bloqueia HTTP, endereços privados, loopback e redes especiais, inclusive quando o DNS resolve para elas. O Mira não segue redirects e recusa URLs com usuário, senha ou fragmento. Não coloque segredos na URL. O envio espera até dez segundos pelos headers da resposta.

## Eventos [#eventos]

| Evento | Quando | Dados adicionais |
| --- | --- | --- |
| `report.created` | Novo report confirmado | Snapshot do report |
| `report.updated` | Alteração efetiva de campos ou tags | Snapshot final e `changes`, com `before` e `after` por campo |
| `comment.created` | Comentário adicionado | Snapshot do report e o `comment` |
| `attachment.created` | Link ou arquivo adicionado | Snapshot do report e o `attachment` |
| `error.created` | Primeira ocorrência de um erro novo | Snapshot do erro em `error` |
| `error.regressed` | Erro resolvido volta a não resolvido numa release com build posterior | Snapshot do erro em `error` |
| `error.spike` | Pico de ocorrências marcado no erro | Snapshot do erro em `error` |
| `digest.daily` | Todo dia às 09:00 no fuso configurado, se o projeto tem reports parados | Resumo em `data.digest` |
| `digest.weekly` | Segunda-feira, quando o resumo semanal do projeto é gravado | Resumo em `data.digest`, com `kind: "weekly"` |
| `webhook.test` | Teste administrativo | `report` nulo e `changes` vazio |

Os eventos `error.*`, `digest.daily` e `digest.weekly` só chegam a destinos que os marcam na configuração. No formulário, os digests aparecem como **Digest diário** e **Resumo semanal**. Um mesmo erro publica `error.spike` no máximo uma vez por hora.

Uma atualização de vários campos produz um único `report.updated`. Repetir uma criação idempotente, ou atualizar com os mesmos valores, não publica evento. Renomear uma tag não publica alteração de report. Excluir uma tag em uso publica `report.updated` para os reports afetados.

## Envelope [#envelope]

Todo evento tem o mesmo envelope:

```json
{
  "id": "5f0b9d0e-3c1a-4b8e-9a53-0c4f6a7d2e11",
  "type": "report.updated",
  "version": 1,
  "occurredAt": "2026-11-04T14:32:10.000Z",
  "projectId": "b7c4f2d8-1e6a-4f0c-8d3b-9a2e5c7f1a40",
  "actor": { "id": "…", "name": "Ana Souza", "email": "ana@empresa.com" },
  "data": {
    "report": { "id": "…", "number": 42, "type": "bug", "title": "…", "status": "triaged" },
    "changes": {
      "status": { "before": "new", "after": "triaged" },
      "priority": { "before": null, "after": "high" }
    }
  }
}
```

| Campo | Conteúdo |
| --- | --- |
| `id` | UUID do evento. É o mesmo em todas as tentativas |
| `type` | Um dos tipos da tabela acima |
| `version` | Sempre `1` |
| `occurredAt` | Data ISO 8601 |
| `projectId` | UUID do projeto |
| `actor` | Quem causou o evento, com `id`, `name` e `email`. Nulo nos eventos `error.*` e `digest.*` |
| `data` | `report`, `changes` e, conforme o tipo, `comment`, `attachment`, `error` ou `digest` |

Aceite campos novos dentro do contrato v1 e confira `type` antes de interpretar `data`.

### Snapshot do report [#snapshot-do-report]

`data.report` traz `id`, `projectId`, `number`, `type`, `title`, `description`, `priority`, `status`, `reporter`, `assignee`, `environmentId`, `environment`, `pageUrl`, `tags`, `attachments`, `createdAt` e `updatedAt`. Cada item de `attachments` traz `id`, `kind`, `name`, `contentType`, `sizeBytes`, `url` (só em links) e `createdAt`. O snapshot não leva o contexto técnico, a metadata dos anexos nem o conteúdo dos arquivos.

Campos de texto e URLs podem conter dados pessoais digitados por quem reportou. Restrinja o acesso e a retenção no receptor.

Ao marcar um duplicado, `changes` inclui `duplicateOfId` e o novo status.

### Eventos de comentário e anexo [#eventos-de-comentário-e-anexo]

`comment.created` acrescenta `data.comment` com `id`, `reportId`, `author`, `body`, `origin`, `agentBadge`, `parentId`, `createdAt` e `editedAt`. `attachment.created` acrescenta `data.attachment` com os campos de anexo descritos acima.

### Eventos de erro [#eventos-de-erro]

Nos eventos `error.*`, `actor` e `data.report` são nulos, `changes` é vazio e `data.error` traz o estado do erro:

```json
{
  "id": "…",
  "type": "error.created",
  "version": 1,
  "occurredAt": "2026-11-04T14:40:02.000Z",
  "projectId": "…",
  "actor": null,
  "data": {
    "report": null,
    "changes": {},
    "error": {
      "id": "…",
      "environmentId": "…",
      "fingerprint": "…",
      "type": "TypeError",
      "message": "Cannot read properties of undefined (reading 'total')",
      "culprit": "src/checkout/summary.ts in computeTotal",
      "level": "error",
      "status": "unresolved",
      "eventCount": 1,
      "userCount": 1,
      "firstSeenAt": "2026-11-04T14:40:02.000Z",
      "lastSeenAt": "2026-11-04T14:40:02.000Z",
      "firstRelease": "1.4.0+abc1234",
      "lastRelease": "1.4.0+abc1234",
      "resolvedAt": null,
      "spikeDetectedAt": null,
      "regressedAt": null
    }
  }
}
```

O snapshot também inclui `projectId`, `resolvedBuildTime` e `regressionEnabled`.

### Digests [#digests]

`digest.daily` e `digest.weekly` trazem `data.digest`, com:

- `kind` (`daily` ou `weekly`), `generatedAt`, `timezone`, `project` (`id`, `key`, `name`) e `url` do projeto.
- `counts` com os totais de `aging` (envelhecendo), `unassigned` (sem responsável) e `urgentStalled` (urgentes parados).
- as listas `aging`, `unassigned` e `urgentStalled`, com até 20 reports cada, onde cada item traz `number`, `title`, `type`, `priority`, `assignee`, `createdAt`, `lastActivityAt` e `url`.
- No semanal, `period` com `start` e `end` (a semana anterior, de segunda a segunda) e `summary` com `body`, `model` e `period`.

O digest diário só sai quando o projeto tem ao menos um report em alguma das três listas. O semanal sai quando o resumo escrito pelo assistente é gravado, mesmo com as listas vazias.

## Verificar a assinatura [#verificar-a-assinatura]

Cada POST inclui:

```text
Content-Type: application/json
Mira-Webhook-Id: UUID do evento
Mira-Webhook-Timestamp: Unix timestamp em segundos
Mira-Webhook-Signature: v1=HMAC hexadecimal
```

A chave é o segredo completo (`mira_whsec_...`), usado como string UTF-8. O HMAC usa SHA-256 sobre `id.timestamp.corpo`, onde `corpo` são os bytes exatos recebidos, antes de qualquer parse. Durante uma rotação de segredo, o header traz duas assinaturas separadas por vírgula. Aceite a mensagem se uma delas bater.

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

function verifyWebhook(
  secret: string,
  id: string,
  timestamp: string,
  signatures: string,
  rawBody: Buffer,
): boolean {
  if (!/^\d+$/.test(timestamp)) return false;
  const seconds = Number(timestamp);
  if (!Number.isSafeInteger(seconds)) return false;
  if (Math.abs(Date.now() / 1000 - seconds) > 300) return false;
  const expected = createHmac("sha256", secret)
    .update(`${id}.${timestamp}.`)
    .update(rawBody)
    .digest();
  return signatures.split(",").some((entry) => {
    const candidate = entry.trim();
    if (!/^v1=[a-f0-9]{64}$/.test(candidate)) return false;
    return timingSafeEqual(Buffer.from(candidate.slice(3), "hex"), expected);
  });
}
```

<Callout type="warning" title="Cuidados no receptor">
Rejeite headers ausentes antes de chamar a função. A janela de cinco minutos exige relógios sincronizados. Armazene o `Mira-Webhook-Id` para deduplicar, e só responda 2xx depois de aceitar o trabalho de forma durável. Uma resposta 2xx confirma a entrega, mesmo sem corpo.
</Callout>

## Tentativas e histórico [#tentativas-e-histórico]

Cada ciclo tem seis tentativas: a inicial e cinco retries, depois de 1 minuto, 5 minutos, 15 minutos, 1 hora e 6 horas. Qualquer resposta fora de 2xx, falha de DNS, conexão, TLS ou timeout conta como falha. O Mira registra início, conclusão, status HTTP, duração e um código de erro de cada tentativa, mas não o corpo nem os headers da resposta.

As entregas podem se repetir e chegar fora de ordem. Não há garantia de execução exatamente uma vez no receptor, então trate o `id` como chave de idempotência. A nova tentativa usa o mesmo `id` e o mesmo corpo, com timestamp e assinatura novos.

Depois da última falha, **Reenviar** inicia outro ciclo. Não há reenvio de entrega já confirmada. O histórico, com payload e tentativas, é visível só para admins. Pausar um destino cancela as pendências, e reativar não reproduz o que aconteceu durante a pausa. Excluir impede novos envios e preserva o histórico.

## Rotação do segredo [#rotação-do-segredo]

Em **Webhooks**, gere um novo segredo. A chave nova aparece uma vez. Atualize o receptor em até 15 minutos: durante essa janela o Mira assina com a chave nova e a anterior, e depois usa só a nova. Outra rotação fica bloqueada até o fim da janela.

## Próximos passos [#próximos-passos]

<Cards>
<Card href="/desenvolvedores/api" title="API externa" description="Busque o report completo quando o snapshot não bastar." />
<Card href="/desenvolvedores/tokens" title="Tokens" description="Credenciais para consultar o Mira depois de receber o evento." />
</Cards>
