# Webhooks de Chamados de Suporte

## Visão Geral

Se você abrir tickets através da [Referência da API de Suporte ao Parceiro](/partners), não precisa fazer polling em `GET /api/partners/tickets` para saber que um caso foi movido. Dê-nos uma URL HTTPS e nós `POST` um evento JSON para ela sempre que um dos seus tickets mudar — uma mudança de status, uma resolução, uma resposta da nossa equipe de suporte, uma mudança de prioridade ou um novo título.

A API não desaparece. Ela continua sendo a autoridade: você continua usando-a para abrir tickets, postar acompanhamentos e reconciliar. O webhook é o caminho rápido, não o único.

```
POST https://your-endpoint.example.com/pluggy/tickets
Content-Type: application/json
X-Pluggy-Signature: t=1758202927,v1=6f9c…
X-Pluggy-Event-Id: 0f2b6c4e-7a1d-4a2e-9c3f-5b8d1e0a7c44
```

<Callout variant="info" title="Somente respostas visíveis para o cliente são enviadas">
Nosso suporte mantém notas internas sobre os mesmos tickets. Elas nunca são emitidas. `ticket.message_added` carrega uma resposta escrita *para você* e nada mais — o filtro é positivo (uma resposta pública da nossa equipe), então qualquer coisa nova e interna é excluída por padrão, em vez de incluída até que alguém se lembre de excluí-la. Suas próprias mensagens também não são ecoadas de volta para você.
</Callout>

## Configurando

1. **Envie-nos uma URL HTTPS.** Uma por ambiente se você quiser validar contra um consumidor separado primeiro. HTTP simples é rejeitado.
2. **Retornamos um segredo de assinatura**, do formato `whsec_` seguido por 64 caracteres hexadecimais. Ele é mostrado **uma vez**, no momento da geração, e não podemos lê-lo de volta para você depois — armazene-o como um segredo do seu lado. Se for perdido ou vazado, peça-nos para girá-lo; o novo segredo substitui o antigo imediatamente, então faça a troca em um passo.
3. **Habilitamos a entrega.** A partir desse momento, toda mudança em um ticket que pertence à sua conta produz um evento.

Seu endpoint deve responder a qualquer `2xx` assim que tiver aceitado duravelmente o evento — coloque-o na fila e processe de forma assíncrona. Esperamos **8 segundos** por uma resposta; qualquer coisa mais lenta é tratada como uma entrega falhada e é tentada novamente.

## A carga útil

Toda entrega tem o mesmo envelope:

| Campo | Significado |
| --- | --- |
| `eventId` | UUID, único por evento e **estável em tentativas**. Sua chave de idempotência. Também enviado como o cabeçalho `X-Pluggy-Event-Id`. |
| `sequence` | Monotônico, atribuído quando o evento é criado. O `sequence` crescente é a ordem de criação — não é a ordem de entrega, veja abaixo. |
| `event` | Um dos cinco tipos de eventos abaixo. |
| `occurredAt` | Quando observamos a mudança. Uma nova tentativa não altera isso. |
| `ticket` | O ticket após a mudança: `key`, `number` (número do nosso suporte), `title`, `state`, `status`, `updatedAt`, `portalUrl` e `priority` onde sabemos. |
| `previous` | Presente apenas onde há um valor anterior — `state`/`status`, `priority` ou `title`. |
| `message` | Presente apenas em `ticket.message_added`. |

O bloco do ticket é deliberadamente pequeno. Ele diz o que mudou; `GET /api/partners/tickets/{key}` é a autoridade sobre tudo o mais.

`state` é o bucket de ciclo de vida grosso e é um dos `new`, `on_you`, `on_customer`, `on_hold` ou `closed`. `status` é o rótulo legível que o acompanha — `New`, `On you`, `On customer`, `Aguardando Detentora`, `Aguardando Engenharia`, `Closed` — e um status que ainda não mapeamos chega como ele mesmo, então **combine com `state` e trate `status` como texto de exibição**.

Cada timestamp é UTC, RFC 3339, com sufixo `Z`. `ticket.updatedAt` e `message.sentAt` se originam no sistema de tickets, que os reporta com um deslocamento; nós os normalizamos antes de enviar, então uma carga útil nunca carrega dois formatos.

## Verificando a assinatura

`X-Pluggy-Signature` é formatado como `t=<segundos unix>,v1=<hmac hex>`. A string assinada é `"{t}.{corpo bruto}"` — o corpo da requisição **exatamente como chegou na rede**, antes de qualquer análise JSON. Re-serializar o objeto analisado muda os bytes e a assinatura não corresponderá.

`t` é também o token anti-replay: rejeite uma requisição cujo `t` seja mais velho que cinco minutos.

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

const TOLERANCE_SECONDS = 5 * 60;

export function verifyPluggySignature(rawBody, header, secret) {
  // header: "t=1758202927,v1=6f9c…"
  const parts = new Map(
    String(header ?? "")
      .split(",")
      .map((part) => {
        const i = part.indexOf("=");
        return [part.slice(0, i).trim(), part.slice(i + 1).trim()];
      }),
  );

  const timestamp = Number(parts.get("t"));
  const received = parts.get("v1");
  if (!Number.isFinite(timestamp) || !received) return false;

  // Anti-replay: uma assinatura antiga ainda é uma assinatura válida.
  if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > TOLERANCE_SECONDS) return false;

  const expected = createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`)
    .digest("hex");

  const a = Buffer.from(expected, "utf8");
  const b = Buffer.from(received, "utf8");
  return a.length === b.length && timingSafeEqual(a, b);
}
```

Integrado em um aplicativo Express, mantendo o corpo bruto:

```js
import express from "express";

const app = express();

app.post(
  "/pluggy/tickets",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const raw = req.body.toString("utf8"); // os bytes que assinamos
    if (!verifyPluggySignature(raw, req.get("X-Pluggy-Signature"), process.env.PLUGGY_WEBHOOK_SECRET)) {
      return res.status(401).end();
    }

    const event = JSON.parse(raw);
    enqueue(event); // de-duplicar em event.eventId, depois processar
    res.status(200).end();
  },
);
```

Uma verificação que você pode executar contra uma entrega que você capturou: `createHmac("sha256", secret).update(t + "." + rawBody).digest("hex")` deve ser igual ao valor `v1`, caractere por caractere.

## Eventos

### `ticket.status_changed`

O ticket se moveu no fluxo de trabalho — `state`, o status do portal, ou ambos. `previous` carrega o que eles eram.

```json
{
  "eventId": "0f2b6c4e-7a1d-4a2e-9c3f-5b8d1e0a7c44",
  "sequence": 1042,
  "event": "ticket.status_changed",
  "occurredAt": "2026-09-18T13:42:07.912Z",
  "ticket": {
    "key": "SUP2-1234",
    "number": 418,
    "title": "[YourOrg/Itaú] Transação não retornada (YOUR-1947)",
    "state": "on_you",
    "status": "On you",
    "updatedAt": "2026-09-18T13:41:58Z",
    "portalUrl": "https://pluggy.atlassian.net/servicedesk/customer/portal/1/SUP2-1234"
  },
  "previous": { "state": "new", "status": "New" }
}
```

### `ticket.resolved`

O ticket foi fechado. Ele é enviado **em vez de** `ticket.status_changed`, nunca em adição: uma mudança no mundo real produz um evento, então um consumidor que age em ambos não age duas vezes. Não há bloco `previous`.

```json
{
  "eventId": "7c1a55d2-3e64-4b0a-8f2d-9a0c6e4b21f7",
  "sequence": 1043,
  "event": "ticket.resolved",
  "occurredAt": "2026-09-18T18:05:31.004Z",
  "ticket": {
    "key": "SUP2-1234",
    "number": 418,
    "title": "[YourOrg/Itaú] Transação não retornada (YOUR-1947)",
    "state": "closed",
    "status": "Closed",
    "updatedAt": "2026-09-18T18:05:12Z",
    "portalUrl": "https://pluggy.atlassian.net/servicedesk/customer/portal/1/SUP2-1234"
  }
}
```

### `ticket.message_added`

Uma resposta da nossa equipe de suporte foi publicada no ticket. Este é o único evento que carrega conteúdo: `message.text` é a resposta escrita, em texto simples.

`ticket.updatedAt` neste evento é o momento em que a mensagem foi enviada.

```json
{
  "eventId": "b83f0c19-55ad-4c6e-9f41-2d7e8ab10c53",
  "sequence": 1044,
  "event": "ticket.message_added",
  "occurredAt": "2026-09-18T15:20:44.881Z",
  "ticket": {
    "key": "SUP2-1234",
    "number": 418,
    "title": "[YourOrg/Itaú] Transação não retornada (YOUR-1947)",
    "state": "on_you",
    "status": "On you",
    "updatedAt": "2026-09-18T15:20:31Z",
    "portalUrl": "https://pluggy.atlassian.net/servicedesk/customer/portal/1/SUP2-1234"
  },
  "message": {
    "author": "Pluggy Support",
    "text": "Identificamos a causa na coleta da fatura fechada e o ajuste entrou em produção hoje. Pode validar nos itens afetados e nos confirmar?",
    "sentAt": "2026-09-18T15:20:31Z"
  }
}
```

### `ticket.priority_changed`

A prioridade do ticket mudou. `previous.priority` é o valor que tinha antes.

O novo valor é `ticket.priority`, e `previous.priority` é o que era. Ambos são os próprios nomes do sistema de tickets (`Highest`, `High`, `Medium`, `Low`), que não são os rótulos `P0 — Crítico … P3 — Baixo` que o endpoint de criação aceita — o mesmo conceito em dois vocabulários, que preferimos te contar do que você descobrir.

A primeira vez que observamos uma prioridade em um ticket não é uma mudança e não produz evento.

```json
{
  "eventId": "2ad9e5b7-1c08-49f3-b6a1-70c2d4e95f18",
  "sequence": 1045,
  "event": "ticket.priority_changed",
  "occurredAt": "2026-09-18T14:02:19.377Z",
  "ticket": {
    "key": "SUP2-1234",
    "number": 418,
    "title": "[YourOrg/Itaú] Transação não retornada (YOUR-1947)",
    "state": "on_you",
    "status": "On you",
    "updatedAt": "2026-09-18T14:02:05Z",
    "portalUrl": "https://pluggy.atlassian.net/servicedesk/customer/portal/1/SUP2-1234"
  },
  "previous": { "priority": "Medium" }
}
```

### `ticket.updated`

O título do ticket mudou. Não é enviado quando o ticket é fechado na mesma passagem — a resolução é o evento que importa ali.

```json
{
  "eventId": "e40b7f36-9c52-4a8d-83b1-6f0a2c5d7e91",
  "sequence": 1046,
  "event": "ticket.updated",
  "occurredAt": "2026-09-18T16:11:02.560Z",
  "ticket": {
    "key": "SUP2-1234",
    "number": 418,
    "title": "[YourOrg/Itaú] Transações do cartão não retornadas (YOUR-1947)",
    "state": "on_you",
    "status": "On you",
    "updatedAt": "2026-09-18T16:10:47Z",
    "portalUrl": "https://pluggy.atlassian.net/servicedesk/customer/portal/1/SUP2-1234"
  },
  "previous": { "title": "[YourOrg/Itaú] Transação não retornada (YOUR-1947)" }
}
```

## Idempotência

**De-duplicate em `eventId`.** É um UUID, único por evento, e não muda quando tentamos novamente — um reenvio do mesmo evento carrega o mesmo id. O mesmo id chegando duas vezes significa a mesma mudança, não duas mudanças.

Você verá repetições na operação normal. Dois caminhos independentes notam que um ticket se moveu (uma notificação ao vivo e uma comparação completa periódica, que é o que torna o espelho inegável), e um reenvio manual pela nossa equipe reutiliza o `eventId` original de propósito.

Trate um evento cujo id você já processou como um no-op e responda `2xx` — um `409` do seu lado parece uma entrega falhada para nós e será tentado novamente.

## Ordenação

**A ordenação não é garantida.** Um evento refeito chega após eventos criados mais tarde do que ele, e duas mudanças a segundos de distância podem chegar em qualquer ordem. O `sequence` informa qual evento foi *criado* primeiro, então você pode descartar um que já foi superado — mas não é a ordem de entrega, e você não deve reconstruir o estado a partir do fluxo. Quando importa, leia o ticket.

A regra que mantém um consumidor correto: **leia o ticket quando receber um evento.** O evento te diz *que* algo se moveu; `GET /api/partners/tickets/{key}` te diz o que é verdade agora.

## Tentativas

Uma entrega é bem-sucedida em qualquer `2xx`. Qualquer outro status, um erro de conexão, ou nenhuma resposta dentro de 8 segundos conta como uma falha e é tentada novamente com backoff:

| Tentativa | Enviada |
| --- | --- |
| 1 | Imediatamente, quando o evento é criado |
| 2 | ~30 segundos depois |
| 3 | ~2 minutos depois |
| 4 | ~10 minutos depois |
| 5 | ~1 hora depois |
| 6 | ~6 horas depois |

**Seis tentativas no total.** Após a sexta falha, a entrega é marcada como falhada e não é tentada novamente automaticamente — peça-nos e podemos reenviá-la manualmente, com o mesmo `eventId`.

Os atrasos são quando uma nova tentativa se torna *devida*; a passagem de nova tentativa roda em uma programação própria, então as duas primeiras tentativas podem chegar um pouco mais tarde do que o atraso nominal. Cada tentativa é registrada do nosso lado — o que enviamos, o que voltou e quantas vezes tentamos — então "você realmente enviou?" tem uma resposta que não é um palpite.

## Lista de verificação antes de entrar em produção

- Verifique a assinatura sobre o **corpo bruto**, e rejeite em caso de discrepância.
- Rejeite um `t` mais velho que cinco minutos.
- De-duplicate em `eventId` antes de fazer qualquer trabalho.
- Responda `2xx` rapidamente, processe de forma assíncrona.
- Releia o ticket em vez de confiar na ordem dos eventos.
- Mantenha o segredo fora do controle de versão e nos avise se precisar ser girado.