CodeGym /Cursos /ChatGPT Apps /Webhooks e integrações externas: assinatura, timeouts, id...

Webhooks e integrações externas: assinatura, timeouts, idempotência

ChatGPT Apps
Nível 15 , Lição 3
Disponível

1. Webhooks no ChatGPT App: quem chama quem, afinal

No mundo clássico do HTTP, tudo é simples: você é o cliente, faz um POST em /api/..., o servidor responde e tudo fica bem. Com webhooks é o contrário: um serviço externo inicia a requisição HTTP para o seu backend quando algo acontece do lado de fora.

No ecossistema de ChatGPT Apps isso aparece em alguns cenários típicos. Por exemplo, o GiftGenius, após criar um checkout via ACP/Instant Checkout, recebe do provedor de pagamentos a notificação payment_succeeded por webhook. Ou um serviço em background de geração de imagens de prévia de presentes envia image_ready quando o render termina. Nesses casos, o ChatGPT e seu servidor MCP já fizeram a parte deles, a bola está com o terceiro serviço, e ele informa o resultado para você via webhook.

A característica chave: a iniciativa está fora do seu sistema. A solicitação pode chegar a qualquer momento e quantas vezes for necessário. Por isso, pense no handler do webhook como um possível ponto mais vulnerável — é para lá que bate toda a internet.

Uma tabelinha para contraste:

Tipo de chamada Quem inicia Exemplo no GiftGenius
Chamada de API comum Você Servidor MCP chama a Stripe API
Webhook Mundo externo Stripe envia payment_succeeded para você

2. Esquema simples: onde entra o ChatGPT, o MCP e o webhook

De forma esquemática, o fluxo fica assim:

sequenceDiagram
    participant User as Usuário no ChatGPT
    participant GPT as ChatGPT + modelo
    participant App as GiftGenius (MCP/App)
    participant PSP as Gateway de pagamento (Stripe/ACP)

    User->>GPT: "Quero comprar um presente"
    GPT->>App: callTool(create_checkout)
    App->>PSP: POST /checkout_sessions
    PSP-->>App: 200 OK + checkout_session_id
    App-->>GPT: ToolOutput (informações do checkout)

    PSP-->>App: POST /webhooks/payment_succeeded
    App-->>PSP: 200 OK (evento recebido)
    App->>DB: marcar o pedido como pago

A primeira parte — requisições de saída comuns — você já sabe fazer. O webhook é a parte inferior do diagrama, quando o gateway de pagamento bate em você. É isso que nos interessa hoje.

3. Handler básico de webhook no Next.js (esqueleto)

Seguimos desenvolvendo nosso GiftGenius didático em Next.js 16. No template temos app/ com o UI e app/mcp/route.ts com o servidor MCP.

É lógico colocar o handler do webhook em uma rota HTTP separada, por exemplo: app/api/webhooks/commerce/route.ts.

O esqueleto mínimo fica assim:


// app/api/webhooks/commerce/route.ts
import { NextRequest } from "next/server";

export async function POST(req: NextRequest) {
  const rawBody = await req.text();          // 1. Lemos o corpo como string
  const headers = Object.fromEntries(req.headers); // 2. Pegamos os headers

  // 3. TODO: validação da assinatura (vamos adicionar já já)
  // 4. TODO: parse de JSON e processamento do evento

  return new Response("ok", { status: 200 }); // 5. Respondemos rapidamente com 2xx
}

Já há algumas ideias importantes embutidas aqui.

Primeiro, lemos o corpo como texto, e não diretamente com await req.json(). Muitos provedores assinam o fluxo de bytes “cru” do corpo e, se você fizer o parse (e pior, reformatar) antes de verificar a assinatura, ela não vai bater.

Segundo, já pensamos em uma resposta rápida 2xx. É melhor mover o trabalho pesado para um worker separado ou pelo menos para uma função assíncrona após registrar o evento nos logs. Isso se relaciona diretamente com timeouts e reenvios, tema de que falaremos em breve.

4. Assinatura de webhooks: como diferenciar o “Stripe” de “alguém com curl”

Relembrando o TODO do esqueleto do handler — “validação da assinatura”. Vamos entender como exatamente distinguir o Stripe real de “alguém com curl”.

A ingenuidade mais comum é achar que, se a URL for complicada (/api/webhooks/stripe/super-secret-abc123), ninguém vai encontrá-la. Segredos em URL são essencialmente security through obscurity: tentar se esconder atrás de uma URL difícil oferece uma proteção muito fraca. A linha de defesa correta é a assinatura criptográfica.

Praticamente todos os provedores sérios (Stripe, ACP, muitas CRMs) calculam uma assinatura HMAC com base no corpo da requisição e no horário, e então colocam o resultado em um header. Você, como destinatário, faz o mesmo e compara. Se houver qualquer divergência — você descarta a requisição como falsificação.

Receita geral:

  1. Você tem o segredo do webhook, obtido no painel do provedor e armazenado nos segredos do ambiente (por exemplo, STRIPE_WEBHOOK_SECRET no Vercel env).
  2. O provedor, ao enviar a requisição, calcula o HMAC de timestamp + '.' + rawBody.
  3. No header, por exemplo Stripe-Signature, coloca o timestamp e uma ou mais assinaturas.
  4. No handler, você pega o timestamp, calcula seu HMAC pela mesma regra e compara.

Mini-exemplo em TypeScript usando crypto:

import crypto from "crypto";

function computeSignature(secret: string, payload: string) {
  return crypto
    .createHmac("sha256", secret)  // escolhemos o algoritmo
    .update(payload, "utf8")       // texto bruto do corpo
    .digest("hex");                // string hex
}

Exemplo de verificação de assinatura e frescor do evento:

const sigHeader = headers["stripe-signature"];
if (!sigHeader) return new Response("missing signature", { status: 400 });

const [tsPart, sigPart] = sigHeader.split(",").map(s => s.trim());
const timestamp = Number(tsPart.split("=")[1]);
const theirSig = sigPart.split("=")[1];

const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > 5 * 60) {
  return new Response("timestamp too old", { status: 400 });
}

const payload = `${timestamp}.${rawBody}`;
const expectedSig = computeSignature(
  process.env.STRIPE_WEBHOOK_SECRET!,
  payload
);

if (!crypto.timingSafeEqual(
  Buffer.from(expectedSig, "hex"),
  Buffer.from(theirSig, "hex")
)) {
  return new Response("invalid signature", { status: 400 });
}

Note o timingSafeEqual — é uma proteção contra ataques por tempo, em que o invasor tenta adivinhar a assinatura pela duração da comparação.

Depois de verificar a assinatura com sucesso, você pode tranquilamente fazer JSON.parse(rawBody) ou await req.json(), sabendo que veio de um provedor real.

Camadas adicionais de defesa como IP allowlist (permitir requisições apenas dos endereços do provedor) e um domínio separado para webhooks ajudam, mas é a assinatura criptográfica que dá a garantia de autenticidade.

5. Timeouts, resposta rápida e processamento assíncrono

Webhooks gostam de quem responde rápido. A maioria das plataformas de pagamentos e commerce espera que seu endpoint responda com 2xx em alguns segundos (muitas vezes até 10 segundos, às vezes menos). Se você “pensar” por tempo demais, elas consideram a chamada malsucedida e começam a reenviar as requisições.

A abordagem ingênua é assim: você verificou a assinatura, foi ao BD, chamou mais três APIs externas, gerou um relatório, criou um PDF e só então retornou 200 OK. Se qualquer uma dessas etapas travar um pouquinho, o provedor julga que o webhook falhou e o enviará novamente. No fim, você cria o pedido duas vezes, envia o email duas vezes, chama alguma GPT tool duas vezes — e corre para apagar o incêndio.

O padrão correto é “recebeu, registrou, adiou”:

  1. Verificar a assinatura e os invariantes básicos (tipo do evento, campos obrigatórios).
  2. Registrar rapidamente o evento em uma tabela/fila (mínimo de operações no BD).
  3. Retornar 2xx.
  4. Processar o evento em background, com um worker separado.

Exemplo simplificado de um handler “meio certo” sem fila dedicada, mas com fixação rápida:

export async function POST(req: NextRequest) {
  const rawBody = await req.text();
  const headers = Object.fromEntries(req.headers);

  if (!verifySignature(headers, rawBody)) {
    return new Response("invalid signature", { status: 400 });
  }

  const event = JSON.parse(rawBody);
  await saveWebhookEvent(event); // gravação rápida no BD

  // Aqui você pode enviar a tarefa para o background via setImmediate/queue,
  // mas no exemplo didático por enquanto ficamos só com o registro: chamamos sem await,
  // para que a 200 saia imediatamente.
  processWebhookEventLater(event).catch(console.error);

  return new Response("ok", { status: 200 });
}

Observe: nós não fazemos await processWebhookEventLater(...). O handler coloca a tarefa em background e retorna 200 imediatamente, para não bater nos timeouts do webhook.

Em produção, nessa parte costuma entrar uma fila (por exemplo, uma tabela separada webhook_jobs ou um serviço externo), e os workers processam os eventos com calma, sem bloquear a recepção de novos.

6. Idempotência e deduplicação: como não cobrar duas vezes

Em exemplos didáticos, adoramos desenhar setas perfeitas: um evento → um processamento → pedido feliz. Na vida real, webhooks chegam como gatinhos fofos — em pacotes e várias vezes seguidas.

As razões são simples: a rede é pouco confiável, timeouts acontecem e muitos provedores, por design, reenviam eventos até receberem um 2xx confiável. Isso é especialmente importante para pagamentos: é melhor reenviar payment_succeeded do que perdê-lo para sempre.

Portanto, sua lógica de negócio deve ser idempotente: reprocessar o mesmo evento não deve mudar o resultado (ou pelo menos não deve quebrar o sistema).

Padrão típico:

  1. O evento tem um identificador estável, por exemplo event.id ou checkout_session_id.
  2. Você o armazena em uma tabela de eventos processados e aplica um índice exclusivo nesse campo.
  3. A cada webhook, primeiro verifica: se já existe um registro com esse id e status “processado”, apenas responde 200 e não faz nada.

Mini-exemplo com pseudo‑ORM:

async function handlePaymentSucceeded(event: any) {
  const existing = await db.webhookEvents.findUnique({
    where: { providerId: event.id },
  });
  if (existing?.processedAt) {
    return; // já fizemos tudo
  }

  await db.$transaction(async (tx) => {
    await tx.webhookEvents.upsert({
      where: { providerId: event.id },
      update: { processedAt: new Date() },
      create: {
        provider: "stripe",
        providerId: event.id,
        type: event.type,
        payload: event,
        processedAt: new Date(),
      },
    });

    await tx.orders.update({
      where: { checkoutSessionId: event.data.object.id },
      data: { status: "PAID" },
    });
  });
}

Aqui a transação é importante: você marca o evento como processado e altera o pedido ao mesmo tempo. Se algo cair no meio, a transação é revertida e, no próximo reenvio do webhook, você tenta de novo — sem dupla gravação.

Também é uma boa prática tornar a própria operação idempotente, por exemplo:

  • “definir o status do pedido como PAID” em vez de “aumentar o saldo em +100”;
  • “criar o registro se não existir” em vez de “adicionar mais uma linha”.

7. Validação de dados do webhook e PII: a assinatura não é o único filtro

Mesmo que o webhook esteja assinado e venha de um serviço real, trate seus dados com a mesma desconfiança que você teria com input de usuário ou argumentos de ferramentas. Na aula passada já discutimos que schemas e normalização são seu firewall.

Um schema para o evento pode ser assim (no nível TypeScript/Zod):

import { z } from "zod";

const paymentSucceededSchema = z.object({
  id: z.string(),
  type: z.literal("payment_succeeded"),
  data: z.object({
    object: z.object({
      id: z.string(),            // checkout_session_id
      amount_total: z.number(),
      currency: z.string(),
      metadata: z.record(z.string(), z.string()).optional(),
    }),
  }),
});

No handler você valida:

const event = JSON.parse(rawBody);
const parsed = paymentSucceededSchema.parse(event);
// a partir daqui, trabalhe apenas com parsed

Assim você se protege de surpresas como “o provedor mudou o formato”, “no ambiente de testes o campo virou nullable” e por aí vai. Se algo não bater — registre o erro nos logs e retorne 400; o provedor vai reenviar depois ou disparar um alerta.

Também é importante lembrar de PII: os corpos de webhooks frequentemente contêm email, endereço de entrega e às vezes até partes de dados de pagamento (tokenizados). Mascarar isso nos logs e não enviar cru para serviços de APM/log de terceiros é uma prática obrigatória, como falamos no tema de segredos e dados confidenciais.

E definitivamente não envie o JSON completo do webhook de volta ao ChatGPT como ToolOutput — o modelo não deve ver tudo que o provedor de pagamento enviou, especialmente se isso não for necessário para o UX.

8. GiftGenius na prática: webhook de pagamento para ACP/Instant Checkout

Voltando ao nosso GiftGenius. No módulo de comércio e ACP, já vimos como o agente cria uma sessão de checkout e como o Instant Checkout faz a cobrança. Do ponto de vista do nosso backend, depois disso resta aguardar o webhook order.paid (ou checkout.session.completed em termos da Stripe) para:

  • fixar o status do pedido;
  • disparar a cadeia “enviar email” / “preparar expedição”;
  • dar ao agente uma resposta segura “o pagamento foi concluído”.

Exemplo de um handler simples no Next.js:

// app/api/webhooks/commerce/route.ts
import { NextRequest } from "next/server";
import { handlePaymentSucceeded } from "@/lib/webhooks/commerce";

export async function POST(req: NextRequest) {
  const rawBody = await req.text();
  const headers = Object.fromEntries(req.headers);

  if (!verifyCommerceSignature(headers, rawBody)) {
    return new Response("invalid signature", { status: 400 });
  }

  const event = JSON.parse(rawBody);
  if (event.type === "payment_succeeded") {
    // Handler idempotente da seção anterior
    await handlePaymentSucceeded(event);
  }

  return new Response("ok", { status: 200 });
}

A função verifyCommerceSignature implementa a lógica de assinatura HMAC, análoga ao que vimos acima. Em um projeto real, faz sentido criar um módulo para cada provedor (verifyStripeSignature, verifyACPCheckoutSignature) para não misturar schemas.

Dentro de handlePaymentSucceeded você:

  • valida o objeto contra o schema (Zod);
  • em uma transação, marca o evento como processado e atualiza o pedido;
  • opcionalmente coloca tarefas na fila para ações “lentas”: emails, analytics, chamadas extra de API.

Essa abordagem torna a cadeia “ACP → webhook → GiftGenius” resiliente a eventos repetidos, falhas temporárias e dados estranhos.

9. Onde os webhooks se conectam ao MCP, ao ChatGPT e às ferramentas

À primeira vista, webhooks vivem separados do ChatGPT App: alguma rota HTTP no backend e pronto. Na verdade, são uma parte importante da arquitetura geral.

Geralmente o encadeamento fica assim:

  1. A ferramenta MCP create_checkout é chamada pelo modelo no ChatGPT.
  2. O servidor MCP fala com o gateway de pagamento, cria a sessão de checkout e retorna no ToolOutput informações do pedido e status “aguardando pagamento”.
  3. O usuário conclui o pagamento no UI (o Instant Checkout faz isso diretamente no ChatGPT).
  4. O gateway de pagamento envia o webhook para seu backend.
  5. O backend altera o status do pedido no BD; na próxima chamada de ferramentas ou follow‑up do modelo já dá para dizer: “O pedido foi pago, aqui estão os detalhes”.

Às vezes o backend pode iniciar um follow‑up indiretamente — por exemplo, por meio de um widget ou integração Realtime que, mediante um sinal do servidor, chama sendFollowUpMessage. Mas, mesmo que isso não exista, o fato do pagamento fica salvo com você e, na próxima chamada da ferramenta, o backend lê o novo status no BD e retorna ao modelo dados atualizados para a resposta.

Importante: o webhook é um ponto de entrada que vive no mesmo nível do servidor MCP e usa os mesmos serviços (BD, filas, segredos). A lógica de segurança é basicamente a mesma: privilégios mínimos, dados de entrada validados, logs cuidadosos.

10. Erros comuns ao trabalhar com webhooks e integrações externas

Erro nº 1: ausência de verificação da assinatura do webhook.
Às vezes os desenvolvedores se contentam com uma URL “secreta” ou um simples Bearer my-secret no header. Se esse segredo vazar em algum lugar, qualquer um pode disparar webhooks em você, criando pedidos, alterando status de pagamentos e fazendo o que quiser. A abordagem correta é assinatura criptográfica do corpo (HMAC) e verificação do timestamp. Isso torna a falsificação muito mais difícil do que “adivinhar a URL”.

Erro nº 2: processamento pesado dentro da requisição do webhook.
Escrever no handler do webhook “criar pedido, chamar duas APIs externas, gerar PDF, chamar um modelo GPT, enviar 5 emails” é receita certa para pegar timeouts e tentativas repetidas. No fim, você mesmo gera duplicatas que depois terá de desfazer. É muito mais confiável confirmar rapidamente o recebimento do evento (2xx), registrá-lo no BD ou em uma fila e processar em background.

Erro nº 3: lógica de negócio não idempotente.
É comum ver código do tipo “a cada payment_succeeded, aumentar o saldo pelo valor”. Se o webhook vier duas vezes, o saldo dobra. Outra variante — criar duas vezes o mesmo pedido ou enviar email em duplicidade. A idempotência se obtém via identificador estável do evento, tabela de eventos processados, transações e operações do tipo “definir status” em vez de “somar mais”.

Erro nº 4: ausência de schemas e validação dos dados do webhook.
Mesmo um webhook assinado pode não ser o que você esperava: o provedor mudou o formato, você copiou o JSON da documentação mas no ambiente de testes o campo tem outro nome, ou você simplesmente errou nos tipos. Se processar esse JSON sem schemas e verificações, os erros vão quebrar pedidos silenciosamente ou lançar exceções no meio da cadeia. Usar Zod/JSON Schema na entrada simplifica a diagnose e permite descartar eventos inválidos com clareza.

Erro nº 5: logar corpos brutos de webhooks contendo PII.
No ímpeto de depurar, é fácil colocar um console.log(rawBody) e esquecer. Em produção, isso vira logs cheios de emails, endereços e outras PII, que vão para serviços de log de terceiros. Do ponto de vista de privacidade e regulações (histórias tipo GDPR), é um tiro no pé. Implemente um PII‑scrub desde o início — masque campos sensíveis e logue apenas o necessário para a diagnose.

Erro nº 6: misturar webhooks de teste e de produção.
Situação típica — o mesmo endpoint aceita eventos tanto do ambiente de teste quanto do de produção do provedor. No fim, um pagamento de teste altera o status de um pedido real, ou vice‑versa. É mais seguro separar URLs (por exemplo, /webhooks/commerce/test e /webhooks/commerce/live) ou, ao menos, guardar o “modo” na configuração e validá-lo na entrada.

Erro nº 7: depender totalmente do webhook síncrono no roteiro do ChatGPT.
Às vezes queremos que, após a chamada da ferramenta e a criação da sessão de checkout, o modelo já saiba o resultado do pagamento. Mas webhooks, por definição, são assíncronos, e o pagamento pode levar tempo. Construir o cenário como se tudo fosse instantâneo é uma má ideia. É melhor projetar diálogos e ferramentas para conviver bem com eventos atrasados: salvar o estado do pedido, permitir que o usuário volte ao chat e receba as informações atualizadas depois.

Comentários
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION