Como receber um webhook toda vez que você compra algo

Receba um POST quando cair movimentação nova na conta ou no cartão: endpoints, payloads, validação de assinatura e o atalho de avisar direto num canal do Discord ou Slack.

Executar com IA

Pule a leitura: mande este guia como um prompt pronto pra rodar. Ele instrui o assistente a parar e te perguntar sempre que precisar de uma credencial ou de um passo que só você pode fazer.

O Claude Code abre com o prompt preenchido mas não enviado, pra você revisar antes.

Toda vez que uma compra nova cai na sua conta ou no seu cartão, o Banco MCP pode avisar o seu sistema — sem você ficar consultando extrato de minuto em minuto.

Neste guia você configura um webhook do zero: descobre o id da sua instalação, assina o evento certo, recebe o callback, valida a assinatura e busca a transação que disparou tudo. No fim, um atalho: mandar o aviso direto pra um canal do Discord ou do Slack, sem escrever backend nenhum.

Como o evento chega até você

O caminho é este:

  1. O banco publica a movimentação no Open Finance.
  2. O provedor sincroniza a conexão e nos avisa.
  3. O MCP.AI descobre de qual instalação é aquela conexão e faz um POST no seu endpoint.

O ponto que economiza mais tempo de debug: o callback é um toque, não um extrato. Ele diz "essa conexão mexeu", com o id da conexão — não vem valor, nem estabelecimento, nem descrição. Nenhum dado bancário atravessa este webhook. Quem quer saber o que foi comprado faz uma segunda chamada, no passo 7.

Antes de começar

Você vai precisar de:

  • O Banco MCP instalado e pelo menos um banco conectado.
  • Uma Workspace API key (sk_live_…), criada em Configurações → API keys no app.mcp.ai.
  • Um endpoint HTTPS público que aceite POST. Endereço http://, localhost, 127.0.0.1 e .local são recusados — em desenvolvimento, use um túnel com hostname https (ngrok, Cloudflare Tunnel e afins).

A key precisa ser de workspace. Key com escopo de credencial (travada num MCP + conexão) é só pro espelho REST e leva 403 nas rotas de webhook.

Passo 1 — Descobrir o id da instalação

O evento de movimentação bancária nasce numa instalação (mi_…), e é lá que ele aparece no catálogo de eventos. Liste o que você tem instalado:

bash
curl -s https://app.mcp.ai/api/mcps \
  -H "Authorization: Bearer sk_live_..."

Procure a entrada do Banco MCP e guarde o id — é um mi_….

Se você prefere delegar, o mesmo passo em linguagem natural:

Prompt
Liste os MCPs instalados no meu workspace do MCP.AI chamando
GET https://app.mcp.ai/api/mcps com o header Authorization: Bearer <minha workspace API key>.
Me diga o id (mi_...) da instalação do Banco MCP.

Passo 2 — Ver quais eventos existem

Nunca chute nome de evento. Cada MCP declara os seus, e a própria API devolve a lista:

bash
curl -s https://api.mcp.ai/api/installs/mi_ABC123/webhook \
  -H "Authorization: Bearer sk_live_..."

A resposta traz a configuração atual (vazia, se você ainda não criou nada), o catálogo de eventos e o que dá pra escolher em formato e transporte:

json
{
  "install_id": "mi_ABC123",
  "mcp_slug": "openfinance",
  "url": null,
  "events": [],
  "include_result": true,
  "enabled": true,
  "format": "json",
  "method": "POST",
  "headers": {},
  "has_secret": false,
  "available_events": [
    { "id": "tool.call" },
    { "id": "disconnect" },
    { "id": "connection.created" },
    { "id": "pluggy.inbound" },
    { "id": "pluggy.*", "wildcard": true }
  ],
  "available_formats": ["json", "discord", "slack"],
  "available_methods": ["POST", "PUT", "PATCH"]
}

Repare: não existe um evento transaction.created. Movimentação nova chega como pluggy.inbound — o tipo específico vem dentro do payload, no campo pluggy_event. É esse o evento que você quer assinar.

Duas coisas que a leitura não devolve, de propósito: o secret (você recebe só has_secret, dizendo se já existe um) e o valor dos headers customizados (voltam com o nome em claro e o valor mascarado).

Passo 3 — Criar o webhook

Não existe POST aqui: criar e atualizar são o mesmo PUT.

bash
curl -X PUT https://api.mcp.ai/api/installs/mi_ABC123/webhook \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://meuapp.com/hooks/banco",
    "events": ["pluggy.inbound"],
    "secret": "um-segredo-longo-e-aleatorio"
  }'

Os campos aceitos:

CampoObrigatórioDefaultO que faz
urlsimEndpoint HTTPS que recebe a entrega. Sem localhost/.local.
eventsnãotodosEventos assinados. Lista vazia ou ausente = todos.
enablednãotruefalse mantém a configuração e para a entrega.
secretnãoLiga a assinatura HMAC. Ver a regra abaixo.
formatnãojsonjson, discord ou slack — como o corpo é apresentado.
methodnãoPOSTPOST, PUT ou PATCH.
headersnão{}Até 10 headers customizados (ex.: autenticar no seu endpoint).
include_resultnãotrueSó afeta tool.call.

⚠️ O PUT reescreve o documento inteiro. Campo que você omitir volta ao default: sem events você passa a receber tudo, sem headers os customizados somem. Sempre faça o GET do passo 2 antes de alterar algo.

O secret é a única exceção — e ela existe justamente porque a leitura não devolve o valor:

  • ausente → mantém o que já estava guardado
  • valor → troca
  • string vazia → limpa (as entregas deixam de ser assinadas)

Em vez do curl, você pode pedir pro seu agente:

Prompt
Configure o webhook da minha instalação do Banco MCP no MCP.AI.

1. Primeiro leia a config atual: GET https://api.mcp.ai/api/installs/<meu mi_...>/webhook
   com Authorization: Bearer <minha workspace API key>.
2. Confira em available_events que "pluggy.inbound" existe.
3. Depois faça PUT no mesmo endereço com este body:
   { "url": "https://meuapp.com/hooks/banco", "events": ["pluggy.inbound"], "secret": "<meu segredo>" }

Atenção: o PUT reescreve o documento inteiro, então reenvie os campos que já
estavam configurados (menos o secret, que é mantido quando você omite). Não
invente nomes de evento fora de available_events.

Para desligar temporariamente, mande "enabled": false. Para remover de vez, DELETE no mesmo endereço.

A resposta do PUT ecoa o documento salvo — inclusive o secret em claro, se houver. É a única superfície que devolve ele; não jogue esse retorno em log.

Passo 4 — O que o seu endpoint recebe

Quando uma compra nova é sincronizada, chega isto:

json
{
  "event": "pluggy.inbound",
  "provider": "pluggy",
  "pluggy_event": "transactions/created",
  "external_id": "0f2c5a1e-1111-2222-3333-444455556666",
  "itemId": "0f2c5a1e-1111-2222-3333-444455556666",
  "clientUserId": "usr_...",
  "install_id": "mi_ABC123",
  "account_id": "acc_...",
  "connection": {
    "key": "0f2c5a1e-1111-2222-3333-444455556666",
    "label": null,
    "connector_name": "Nubank",
    "status": "UPDATED"
  },
  "triggered_at": "2026-08-07T20:11:02.345Z"
}

Filtre por pluggy_event para separar movimentação de outros sinais da conexão (item/updated, erro de login etc.). E guarde o itemId — é a chave do passo 7.

Passo 5 — Validar a assinatura

Se você configurou um secret, toda entrega vem com o header:

http
X-MCP-Signature: t=1754596262345,v1=9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08

A assinatura é um HMAC-SHA256 da string `${t}.${corpo}`, com o secret como chave. Duas regras que costumam derrubar a validação:

  • Use o corpo cru da requisição, byte a byte. Se você fizer JSON.stringify(req.body) para reconstruir, a assinatura não bate. Vale também quando o format é discord/slack: o que é assinado são os bytes renderizados, os mesmos que foram enviados.
  • Compare em tempo constante.

Em Node/Express:

js
import crypto from "node:crypto";
import express from "express";

const app = express();

app.post(
  "/hooks/banco",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const header = req.get("X-MCP-Signature") || "";
    const t = header.match(/t=(\d+)/)?.[1];
    const v1 = header.match(/v1=([a-f0-9]+)/)?.[1];
    if (!t || !v1) return res.sendStatus(401);

    // Rejeita entrega velha (proteção contra replay).
    if (Math.abs(Date.now() - Number(t)) > 5 * 60 * 1000) return res.sendStatus(401);

    const expected = crypto
      .createHmac("sha256", process.env.MCP_WEBHOOK_SECRET)
      .update(`${t}.${req.body}`) // req.body é o Buffer cru
      .digest("hex");

    const ok = crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
    if (!ok) return res.sendStatus(401);

    const payload = JSON.parse(req.body.toString("utf8"));
    // ... enfileira o processamento e responde rápido
    res.sendStatus(200);
  },
);

Sem secret configurado, o header simplesmente não vem.

Passo 6 — Sem backend: avisar no Discord ou no Slack

Se você só quer ver a movimentação chegando, pule o servidor. Crie um webhook de canal no Discord (ou um incoming webhook no Slack), cole a URL e escolha o formato:

bash
curl -X PUT https://api.mcp.ai/api/installs/mi_ABC123/webhook \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://discord.com/api/webhooks/.../...",
    "events": ["pluggy.inbound"],
    "format": "discord"
  }'

O corpo sai como um embed do Discord (ou attachment do Slack) em vez de JSON cru — canal de chat recusa JSON cru com 400.

Duas ressalvas: o card só mostra campos escalares, então o bloco connection { … } não aparece (o connector_name, o nome do banco, fica de fora); e a assinatura passa a cobrir os bytes renderizados. Para integrar de verdade, use format: "json" com o seu endpoint.

Precisa autenticar no seu endpoint? Use headers:

json
{ "headers": { "X-Api-Key": "a-chave-do-meu-servico" } }

São até 10, e a leitura devolve o valor mascarado (o nome fica em claro). X-MCP-Signature e headers de transporte (Host, Content-Length…) são recusados.

Como o PUT reescreve tudo, reenvie o mapa headers inteiro — mas você não precisa redigitar os valores: mandar de volta o valor mascarado que veio do GET é entendido como "não mexi neste", e o valor guardado é preservado.

Passo 7 — Buscar a compra de verdade

O callback te deu o itemId. Agora sim você pega as transações daquela conexão.

Pelo espelho REST:

bash
curl -X POST https://api.mcp.ai/api/openfinance/transactions/by-item \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "item_id": "0f2c5a1e-1111-2222-3333-444455556666",
    "from": "2026-08-01",
    "to": "2026-08-07",
    "granularity": "raw"
  }'

granularity: "raw" traz as linhas individuais; o default (monthly) devolve só o resumo consolidado do período. Se você quiser as transações de uma conta específica em vez da conexão inteira, use openfinance_list_transactions com o account_id.

Pelo agente, o fluxo completo em um prompt:

Prompt
Chegou um webhook do Banco MCP com itemId "0f2c5a1e-1111-2222-3333-444455556666".

Use a tool openfinance_list_transactions_by_item com esse item_id, from = ontem,
to = hoje e granularity = "raw". Me liste só as transações de saída, com data,
descrição e valor, ordenadas da maior para a menor.

Limites que valem saber

  • Entrega no máximo uma vez. Timeout de 15 segundos e sem retry automático. Responda 2xx rápido e processe em background — se o seu endpoint demorar, a entrega é perdida.
  • Idempotência é sua. Guarde o itemId + triggered_at que você já processou. Do nosso lado há deduplicação de replay do provedor, mas o seu handler deve tolerar repetição.
  • Três escopos entregam. Além da instalação (mi_…), o webhook do toolkit e o global do workspace também recebem, desde que assinem o evento (lembre: lista de eventos vazia = assina tudo). Os destinos são deduplicados por URL, e cada um usa o próprio secret, format e headers. O catálogo available_events com pluggy.inbound só aparece no escopo da instalação — nos outros dois você precisa escrever o nome do evento à mão.
  • Correlação é fechada por padrão. Se a conexão do evento não pertence a nenhuma instalação sua, nada é entregue. É por isso que um evento de teste com um itemId inventado não chega em lugar nenhum.

Não chegou nada?

SintomaO que checar
Nenhuma entregaO GET do passo 2 mostra a url que você espera? enabled está true? O evento pluggy.inbound está em events (ou a lista está vazia)?
400 no PUTA URL é https e não aponta pra localhost/.local? Algum header com nome/valor inválido ou na lista de recusados?
403 no PUTA key é de workspace (não escopada num MCP) e do workspace certo?
Assinatura parou de baterVocê mandou "secret": "" num PUT? String vazia limpa o segredo.
Assinatura nunca bateuVocê está assinando o corpo cru, e não o JSON re-serializado?
Chega no Discord mas falta o bancoEsperado: o card só renderiza campos escalares, e connection é um objeto. Use format: "json".
Perdi headers customizados ao salvarO PUT reescreve tudo — reenvie o mapa headers inteiro.

Outros guias