Procurando a documentação anterior?Acesse v1.docs.pluggy.ai
PluggyDocs

Webhooks de Chamados de Suporte

Receba um evento sempre que um dos seus tickets da API de Suporte ao Parceiro mudar — status, resolução, respostas e prioridade — assinado com HMAC-SHA256 e refeito em caso de falha.

Ver como Markdown

Visão Geral#

Se você abrir tickets através da Referência da API de Suporte a Parceiros, não precisa fazer polling em GET /api/partners/tickets para saber que um caso foi movido. Registre um endpoint HTTPS e nós iremos POST um evento JSON para ele 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

Somente respostas visíveis para o cliente são enviadas

Nossa equipe de 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ê.

Configurando#

Você configura seus endpoints por conta própria, através da Referência da API de Suporte a Parceiros e com a mesma chave da API que você usa para abrir tickets. Nenhuma dessas chamadas conta contra sua cota de tickets.

1. Registrar um endpoint#

bash
curl -X POST https://docs.pluggy.ai/api/partners/webhooks \
  -H "Authorization: Bearer $PLUGGY_PARTNER_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://hooks.yourcompany.com/pluggy/tickets",
    "description": "homologação",
    "enabled": false
  }'
json
{
  "endpoint": {
    "id": "3f0b8c1e-6a2d-4c3b-9e1f-2a7d5b8c9e01",
    "url": "https://hooks.yourcompany.com/pluggy/tickets",
    "description": "homologação",
    "enabled": false,
    "secretRotatedAt": "2026-10-27T12:00:00.000Z",
    "createdAt": "2026-10-27T12:00:00.000Z",
    "updatedAt": "2026-10-27T12:00:00.000Z"
  },
  "secret": "whsec_8c1f0e5a3b9d4c7e2f6a1b0c9d8e7f6a5b4c3d2e1f0a9b8c7d6e5f4a3b2c1d0e",
  "routed": true
}

A URL deve ser https, sem credenciais ou um #fragment, e em um nome de host público — endereços IP, localhost e nomes internos são recusados, e o erro informa qual regra a URL quebrou. enabled é padrão como true; enviar false permite que você teste o endpoint antes que ele comece a receber eventos de tickets.

2. Armazenar o secret#

secret é o secret de assinatura para este endpoint: whsec_ seguido por 64 caracteres hexadecimais. Ele está nesta resposta e em nenhum outro lugar — não podemos lê-lo de volta para você depois, e GET nunca o retorna. Armazene-o como um secret do seu lado antes de fazer qualquer outra coisa.

3. Enviar um evento de teste para você#

bash
curl -X POST https://docs.pluggy.ai/api/partners/webhooks/3f0b8c1e-6a2d-4c3b-9e1f-2a7d5b8c9e01/test \
  -H "Authorization: Bearer $PLUGGY_PARTNER_KEY"
json
{
  "delivered": true,
  "responseCode": 200,
  "error": null,
  "eventId": "5d2e9a7b-0c4f-4e1a-b3d6-8f9a0b1c2d3e",
  "durationMs": 143
}

Nós enviamos um evento webhook.test para o endpoint imediatamente, assinado exatamente como um evento de ticket, e informamos o que seu endpoint respondeu. Funciona em um endpoint desativado também. delivered: false vem com o código de status ou a razão — um timeout, um erro de conexão, um redirecionamento (não os seguimos). A chamada em si responde 200 de qualquer forma; é a tentativa que falhou, não a chamada. Execute até que seu endpoint verifique a assinatura e responda 2xx.

4. Habilitá-lo#

bash
curl -X PATCH https://docs.pluggy.ai/api/partners/webhooks/3f0b8c1e-6a2d-4c3b-9e1f-2a7d5b8c9e01 \
  -H "Authorization: Bearer $PLUGGY_PARTNER_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "enabled": true }'

A partir desse momento, toda mudança em um ticket que pertence à sua conta produz um evento. Essa é toda a configuração.

Se uma resposta carregar "routed": false e um warning, seus endpoints estão salvos, mas os eventos de tickets ainda não podem alcançar sua conta — entre em contato conosco e nós terminaremos a configuração do nosso lado.

Renovando um secret#

Se um secret for perdido ou vazado, emita um novo:

bash
curl -X PATCH https://docs.pluggy.ai/api/partners/webhooks/3f0b8c1e-6a2d-4c3b-9e1f-2a7d5b8c9e01 \
  -H "Authorization: Bearer $PLUGGY_PARTNER_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "rotateSecret": true }'

O novo secret volta em secret, uma vez, como na criação. Ele substitui o antigo imediatamente — cada entrega a partir de então é assinada com ele — então faça a troca em um passo. Enviar url em um PATCH também emite um novo secret, mesmo quando é a mesma URL que o endpoint já possui; mudar apenas description ou enabled mantém o atual.

Mais de um endpoint#

Você pode registrar até 10 endpoints — um por ambiente, ou um por sistema que consome os eventos. Cada um tem seu próprio secret, então verifique cada entrega com o secret do endpoint que o recebeu.

Cada evento vai para cada endpoint habilitado. Não há filtro por endpoint: um endpoint de homologação e um endpoint de produção recebem todos os eventos dos seus tickets. Um endpoint desativado não recebe nada, e eventos que acontecem enquanto ele está desativado não são mantidos para ele — habilitá-lo novamente só afeta o que acontece a seguir.

ChamadaO que faz
GET /api/partners/webhooksSeus endpoints, os eventos que eles recebem e se os eventos podem alcançar sua conta. Sem secrets.
POST /api/partners/webhooksRegistrar um endpoint. Retorna seu secret, uma vez.
GET /api/partners/webhooks/{id}Um endpoint. Sem secret.
PATCH /api/partners/webhooks/{id}Mudar url, description ou enabled, ou renovar o secret com rotateSecret.
DELETE /api/partners/webhooks/{id}Remover. Entregas que ainda estão esperando para serem reprocessadas para ele são descartadas. Para pausar em vez disso, PATCH com enabled: false.
POST /api/partners/webhooks/{id}/testEnviar um webhook.test assinado para ele agora.

Os esquemas completos estão na Referência da API de Suporte a Parceiros. Se você preferir não fazer isso sozinho, pergunte-nos — nós também podemos configurá-lo para você.

Respondendo entregas#

Seu endpoint deve responder a qualquer 2xx assim que tiver aceitado duravelmente o evento — coloque-o na fila e processe de forma assíncrona. Nós aguardamos 8 segundos por uma resposta; qualquer coisa mais lenta é tratada como uma entrega falhada e é reprocessada. Redirecionamentos não são seguidos: um 3xx é uma entrega falhada, então registre a URL final.

O payload#

Cada evento de ticket tem o mesmo envelope (o evento webhook.test que você pede é menor, veja abaixo):

CampoSignificado
eventIdUUID, único por evento e estável em reprocessamentos. Sua chave de idempotência. Também enviado como o cabeçalho X-Pluggy-Event-Id.
sequenceMonotônico, atribuído quando o evento é criado. O aumento de sequence é a ordem de criação — não é a ordem de entrega, veja abaixo.
eventUm dos cinco tipos de eventos de ticket abaixo.
occurredAtQuando observamos a mudança. Um reprocessamento não a move.
ticketO ticket após a mudança: key, number (nosso número de mesa), externalId, openedBy, itemId, derivedFromItem, title, state, status, updatedAt, portalUrl, e priority onde sabemos.
previousPresente apenas onde há um valor anterior — state/status, priority ou title.
messagePresente apenas em ticket.message_added.

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

externalId e openedBy são seus próprios: o id que você enviou quando abriu o ticket e quem você disse que o abriu, devolvidos exatamente na forma que GET /api/partners/tickets/{key} os retorna. Eles estão lá para que você possa juntar o evento ao seu próprio registro sem precisar analisar o título. Ambos estão sempre presentes e null quando não temos nenhum — um ticket aberto sem eles, ou antes de começarmos a armazená-los. Eles também são null em um ticket que outra organização abriu e compartilha com a sua: você ainda recebe seus eventos, mas seu externalId e openedBy são deles.

itemId e derivedFromItem (o nome do conector e se é Open Finance) informam qual conexão o ticket está relacionado, para tickets abertos através da API e no portal. Somente a organização que possui o ticket os recebe: em um ticket aberto no portal e compartilhado por várias organizações, eles são null. Eles também são null por alguns minutos após um ticket de portal ser aberto, enquanto lemos seu formulário, e em tickets de pagamento. GET /api/partners/tickets e GET /api/partners/tickets/{key} retornam os mesmos dois campos mais state.

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 — Novo, Em você, No cliente, Aguardando Detentora, Aguardando Engenharia, Fechado — e um status que ainda não mapeamos chega como ele mesmo, então combine em 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 um payload nunca carrega dois formatos.

Verificando a assinatura#

X-Pluggy-Signature é formatado como t=<segundos unix>,v1=<hmac hex>. A string assinada é "{t}.{raw body}" — o corpo da requisição exatamente como chegou pela 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 funcional 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,
    "externalId": "YOUR-1947",
    "openedBy": { "email": "ana@yourcompany.com", "name": "Ana Souza" },
    "title": "[YourOrg/Itaú] Transação não retornada (YOUR-1947)",
    "state": "on_you",
    "status": "Em você",
    "updatedAt": "2026-09-18T13:41:58Z",
    "portalUrl": "https://pluggy.atlassian.net/servicedesk/customer/portal/1/SUP2-1234"
  },
  "previous": { "state": "new", "status": "Novo" }
}

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,
    "externalId": "YOUR-1947",
    "openedBy": { "email": "ana@yourcompany.com", "name": "Ana Souza" },
    "title": "[YourOrg/Itaú] Transação não retornada (YOUR-1947)",
    "state": "closed",
    "status": "Fechado",
    "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 como 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,
    "externalId": "YOUR-1947",
    "openedBy": { "email": "ana@yourcompany.com", "name": "Ana Souza" },
    "title": "[YourOrg/Itaú] Transação não retornada (YOUR-1947)",
    "state": "on_you",
    "status": "Em você",
    "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 informar a você do que fazer 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,
    "externalId": "YOUR-1947",
    "openedBy": { "email": "ana@yourcompany.com", "name": "Ana Souza" },
    "title": "[YourOrg/Itaú] Transação não retornada (YOUR-1947)",
    "state": "on_you",
    "status": "Em você",
    "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,
    "externalId": "YOUR-1947",
    "openedBy": { "email": "ana@yourcompany.com", "name": "Ana Souza" },
    "title": "[YourOrg/Itaú] Transações do cartão não retornadas (YOUR-1947)",
    "state": "on_you",
    "status": "Em você",
    "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)" }
}

webhook.test#

Enviado apenas quando você chama POST /api/partners/webhooks/{id}/test, e apenas para esse endpoint — nunca por conta própria, nunca porque um ticket se moveu. Ele carrega os mesmos cabeçalhos e a mesma assinatura que um evento de ticket, então prova sua verificação de ponta a ponta, mas não tem bloco ticket: endpoint diz a qual de seus endpoints foi enviado.

Verifique-o, responda 2xx, e não faça mais nada com ele. Um consumidor que roteia com base em event deve ignorar um tipo que não conhece em vez de falhar nele.

json
{
  "eventId": "5d2e9a7b-0c4f-4e1a-b3d6-8f9a0b1c2d3e",
  "event": "webhook.test",
  "occurredAt": "2026-10-27T12:05:00.000Z",
  "endpoint": {
    "id": "3f0b8c1e-6a2d-4c3b-9e1f-2a7d5b8c9e01",
    "url": "https://hooks.yourcompany.com/pluggy/tickets"
  }
}

Um teste é enviado uma vez e não é reprocessado: se falhar, conserte o endpoint e envie outro.

Idempotência#

De-duplicate em eventId. É um UUID, único por evento, e não muda quando reprocessamos — 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 é reprocessado.

Ordenação#

A ordenação não é garantida. Um evento reprocessado chega após eventos criados depois dele, e duas mudanças a segundos de distância podem chegar em qualquer ordem. 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 stream. Quando importa, leia o ticket.

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

Reprocessamentos#

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 é reprocessada com backoff:

TentativaEnviado
1Imediatamente, 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 é reprocessada automaticamente — pergunte-nos e podemos reenviá-la manualmente, com o mesmo eventId.

Os atrasos são quando um reprocessamento se torna devido; a passagem de reprocessamento 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.

Reconciliando uma entrega perdida#

Se seu endpoint esteve fora do ar por mais tempo que a janela de reprocessamento, ou se você quiser verificar se nada escapou, GET /api/partners/tickets/{key}/events retorna o histórico do ticket — mudanças de status, prioridade e prazo, nossas respostas públicas e a resolução, da mais antiga para a mais recente, cada uma com sua data. Ele usa os mesmos nomes de eventos que o webhook (ticket.status_changed, ticket.priority_changed, ticket.message_added, ticket.resolved), então você pode combinar o que recebeu com isso. Uma diferença a esperar: um status lá é o nome que o portal de suporte mostra — o mesmo que GET /api/partners/tickets/{key} retorna — não o bucket state do webhook, e seu tempo é quando a mudança foi feita em vez do nosso occurredAt. Como toda leitura, não conta contra sua cota de tickets.

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.
  • Reconcile contra GET /api/partners/tickets/{key}/events após uma interrupção do seu lado.
  • Envie um webhook.test para cada endpoint e veja-o verificado antes de habilitá-lo.
  • Mantenha o secret fora do controle de versão, e renove-o com PATCH … { "rotateSecret": true } se vazar.
Esta página foi útil?