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#
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
}'{
"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ê#
curl -X POST https://docs.pluggy.ai/api/partners/webhooks/3f0b8c1e-6a2d-4c3b-9e1f-2a7d5b8c9e01/test \
-H "Authorization: Bearer $PLUGGY_PARTNER_KEY"{
"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#
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:
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.
| Chamada | O que faz |
|---|---|
GET /api/partners/webhooks | Seus endpoints, os eventos que eles recebem e se os eventos podem alcançar sua conta. Sem secrets. |
POST /api/partners/webhooks | Registrar 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}/test | Enviar 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):
| Campo | Significado |
|---|---|
eventId | UUID, único por evento e estável em reprocessamentos. 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 aumento de sequence é a ordem de criação — não é a ordem de entrega, veja abaixo. |
event | Um dos cinco tipos de eventos de ticket abaixo. |
occurredAt | Quando observamos a mudança. Um reprocessamento não a move. |
ticket | O 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. |
previous | Presente apenas onde há um valor anterior — state/status, priority ou title. |
message | Presente 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.
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:
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.
{
"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.
{
"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.
{
"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.
{
"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.
{
"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.
{
"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:
| Tentativa | Enviado |
|---|---|
| 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 é 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
tmais velho que cinco minutos. - De-duplicate em
eventIdantes de fazer qualquer trabalho. - Responda
2xxrapidamente, processe de forma assíncrona. - Releia o ticket em vez de confiar na ordem dos eventos.
- Reconcile contra
GET /api/partners/tickets/{key}/eventsapós uma interrupção do seu lado. - Envie um
webhook.testpara 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.
