Implementar Webhooks com Claude Code: assinaturas, idempotência e retries
Webhooks de produção com Claude Code: raw body, assinatura, idempotência, retry, testes e runbook.
Webhook é o mecanismo pelo qual um serviço externo avisa sua aplicação por HTTP quando um evento acontece. Pagamento confirmado, push no GitHub, envio de formulário, mudança de assinatura, atualização no CRM e alteração de status em um SaaS são exemplos comuns.
Em produção, um webhook não é apenas um endpoint que lê JSON. Você precisa preservar o raw body, verificar a assinatura do provedor, garantir idempotência, salvar o evento antes de processar, mover trabalho pesado para uma retry queue e ter uma ferramenta de replay. Se você pedir ao Claude Code apenas “crie um webhook”, ele pode gerar um demo que funciona localmente, mas quebra quando o provedor reenvia a mesma entrega.
Para a base de API, leia também desenvolvimento de API com Claude Code, gestão de secrets, boas práticas de segurança e sistemas de fila.
Contrato do provedor
| Item | Exemplo GitHub | Exemplo Stripe | Ponto de implementação |
|---|---|---|---|
| Endpoint | POST /webhooks/github | POST /webhooks/stripe | Rotas separadas |
| ID do evento | X-GitHub-Delivery | event.id | Chave de idempotência |
| Tipo do evento | X-GitHub-Event | event.type | Escolha do handler |
| Header de assinatura | X-Hub-Signature-256 | Stripe-Signature | Verificação |
| Entrada verificada | raw body | raw body | Ordem do body parser |
| Resposta de sucesso | 2xx rápido | 2xx rápido | Salvar e enfileirar |
Use fontes oficiais: GitHub Webhooks, validação de entregas GitHub, Stripe Webhooks, Stripe webhook signatures, Express express.raw e Claude Code best practices.
flowchart LR
A["Provider<br/>GitHub / Stripe"] --> B["Webhook endpoint<br/>raw body"]
B --> C["Signature verification"]
C --> D["Event store"]
D --> E["Idempotency check"]
E --> F["Retry queue"]
F --> G["Domain handler"]
D --> H["Replay tool"]
Prompt para Claude Code
Implemente um receptor de GitHub Webhook em Express + TypeScript.
Requisitos:
- Adicionar POST /webhooks/github
- Preservar raw body com express.raw({ type: "*/*" }) nas rotas webhook
- Fazer JSON parse somente depois da verificação de assinatura
- Verificar X-Hub-Signature-256 com HMAC SHA-256
- Usar X-GitHub-Delivery como chave de idempotência
- Salvar cada evento aceito antes do processamento
- Não processar duas vezes o mesmo delivery id
- Responder 202 rapidamente e processar via retry queue
- Cobrir assinatura válida, assinatura inválida e duplicados com node:test
- Adicionar replay script para entregas salvas
- Ler o segredo de WEBHOOK_SECRET
Receptor executável
npm init -y
npm install express
npm install -D typescript tsx @types/node @types/express
Crie src/server.ts:
import crypto from "node:crypto";
import express from "express";
type EventStatus = "queued" | "processing" | "processed" | "failed";
type WebhookEvent = {
id: string;
provider: "github";
type: string;
headers: Record<string, string>;
rawBody: Buffer;
payload: unknown;
receivedAt: string;
status: EventStatus;
attempts: number;
lastError?: string;
};
export const app = express();
export const eventStore = new Map<string, WebhookEvent>();
export const processedEvents = new Set<string>();
export const retryQueue: string[] = [];
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET ?? "dev-secret-change-me";
app.use("/webhooks", express.raw({ type: "*/*", limit: "1mb" }));
app.use(express.json());
function firstHeader(value: string | string[] | undefined): string | undefined {
return Array.isArray(value) ? value[0] : value;
}
function safeCompare(leftValue: string, rightValue: string): boolean {
const left = Buffer.from(leftValue);
const right = Buffer.from(rightValue);
return left.length === right.length && crypto.timingSafeEqual(left, right);
}
export function signGitHubBody(
rawBody: Buffer | string,
secret = WEBHOOK_SECRET
): string {
return (
"sha256=" +
crypto.createHmac("sha256", secret).update(rawBody).digest("hex")
);
}
export function verifyGitHubSignature(
rawBody: Buffer,
signatureHeader: string | undefined,
secret = WEBHOOK_SECRET
): boolean {
if (!signatureHeader?.startsWith("sha256=")) return false;
return safeCompare(signGitHubBody(rawBody, secret), signatureHeader);
}
function headersForStorage(req: express.Request): Record<string, string> {
const result: Record<string, string> = {};
for (const [key, value] of Object.entries(req.headers)) {
if (typeof value === "string") result[key] = value;
}
return result;
}
app.post("/webhooks/github", (req, res) => {
const rawBody = Buffer.isBuffer(req.body) ? req.body : Buffer.from("");
const signature = firstHeader(req.headers["x-hub-signature-256"]);
const deliveryId = firstHeader(req.headers["x-github-delivery"]);
const eventType = firstHeader(req.headers["x-github-event"]) ?? "unknown";
if (!verifyGitHubSignature(rawBody, signature)) {
return res.status(401).json({ error: "invalid_signature" });
}
if (!deliveryId) {
return res.status(400).json({ error: "missing_delivery_id" });
}
const id = `github:${deliveryId}`;
if (processedEvents.has(id) || eventStore.has(id)) {
return res.status(202).json({ id, status: "duplicate" });
}
let payload: unknown;
try {
payload = JSON.parse(rawBody.toString("utf8"));
} catch {
return res.status(400).json({ error: "invalid_json" });
}
eventStore.set(id, {
id,
provider: "github",
type: eventType,
headers: headersForStorage(req),
rawBody,
payload,
receivedAt: new Date().toISOString(),
status: "queued",
attempts: 0,
});
retryQueue.push(id);
void processNextEvent();
return res.status(202).json({ id, status: "queued" });
});
export async function processNextEvent(): Promise<void> {
const id = retryQueue.shift();
if (!id) return;
const event = eventStore.get(id);
if (!event || event.status === "processed") return;
event.status = "processing";
event.attempts += 1;
try {
await handleWebhookEvent(event);
event.status = "processed";
processedEvents.add(id);
} catch (error) {
event.status = "failed";
event.lastError = error instanceof Error ? error.message : String(error);
if (event.attempts < 5) {
const delayMs = Math.min(30_000, 1_000 * 2 ** event.attempts);
setTimeout(() => {
event.status = "queued";
retryQueue.push(id);
void processNextEvent();
}, delayMs);
}
}
}
async function handleWebhookEvent(event: WebhookEvent): Promise<void> {
if (event.type === "push") console.log("GitHub push received", event.id);
}
if (process.env.NODE_ENV !== "test") {
const port = Number(process.env.PORT ?? 3000);
app.listen(port, () => {
console.log(`Webhook server listening on http://127.0.0.1:${port}`);
});
}
WEBHOOK_SECRET=dev-secret-change-me npx tsx src/server.ts
Envio local e testes
scripts/send-local-webhook.ts:
import crypto from "node:crypto";
const secret = process.env.WEBHOOK_SECRET ?? "dev-secret-change-me";
const url =
process.env.WEBHOOK_URL ?? "http://127.0.0.1:3000/webhooks/github";
const body = JSON.stringify({ ref: "refs/heads/main", after: "local-test" });
const signature =
"sha256=" + crypto.createHmac("sha256", secret).update(body).digest("hex");
const response = await fetch(url, {
method: "POST",
headers: {
"content-type": "application/json",
"x-github-event": "push",
"x-github-delivery": `local-${Date.now()}`,
"x-hub-signature-256": signature,
},
body,
});
console.log(response.status, await response.text());
WEBHOOK_SECRET=dev-secret-change-me npx tsx scripts/send-local-webhook.ts
NODE_ENV=test npx tsx --test test/webhook.test.ts
Para replay, salve url, headers e body sem reformatar. A assinatura HMAC é calculada sobre os bytes exatos recebidos.
Casos de uso
Em pagamentos, eventos Stripe confirmam pedidos, pausam acesso após falha de cobrança e disparam recibos. Em desenvolvimento, push e pull_request do GitHub criam previews, documentação e notificações internas. Em formulários e CRM, o ID externo evita tickets duplicados. Em webhooks enviados pelo seu próprio SaaS, use assinatura, logs de entrega, timeouts, retries e reenvio manual.
Armadilhas comuns
A primeira armadilha é parsear JSON antes de verificar a assinatura. A segunda é fazer trabalho pesado antes de responder 2xx, causando reenvios. A terceira é gerar uma chave de idempotência nova a cada request. A quarta é manter apenas logs, sem delivery original para replay. Também evite secrets e payload completo em logs.
Checklist de produção
- O raw body é preservado apenas nas rotas webhook.
- Assinatura inválida retorna
401; JSON inválido retorna400; evento aceito retorna202. - O delivery ID do provedor é a chave de idempotência.
- O event store salva raw body, headers, status, tentativas e último erro.
- A retry queue tem limite, backoff e alerta de falha final.
- O replay script funciona com entregas salvas.
- Secrets vêm de variável de ambiente ou secret manager.
Resumo
Webhook de produção concentra segurança, confiabilidade e operação em um endpoint pequeno. Claude Code funciona melhor quando o prompt já inclui contrato do provedor, raw body, assinatura, idempotência, fila, testes, replay e runbook.
ClaudeCodeLab oferece modelos em Products e treinamento para equipes em Training. Em testes práticos, começar por raw body e testes de idempotência reduziu bastante o retrabalho gerado depois.
PDF grátis: cheatsheet do Claude Code
Informe seu e-mail e baixe uma página com comandos, hábitos de revisão e workflows seguros.
Cuidamos dos seus dados e não enviamos spam.
Sobre o autor
Masa
Engenheiro focado em workflows práticos com Claude Code.
Artigos relacionados
Permission receipt no Claude Code: escopo, prova e rollback
Padrão de permission receipt para Claude Code: ações permitidas, limites de aprovação, comandos de prova, rollback e CTA de receita.
Agent Harness seguro para Claude Code e Codex: permissoes, verificacao e rollback
Monte uma base segura para agentes com Claude Code e Codex usando politicas, plano, verificacao e recuperacao.
Subagentes no Claude Code: guia prático para delegar trabalho com segurança
Guia prático de subagentes no Claude Code para dividir artigos e código: regras, prompts, riscos e checklist.