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
Crie um log de orçamento Claude Code antes do custo ficar confuso
Registre quem usou Claude Code, para qual trabalho e qual resultado gerou no time.
Checagem de 3 minutos antes do commit: confira o que o Claude Code mexeu antes de fechar
Roteiro de 3 minutos para flagrar antes do commit as mudanças que o Claude Code ampliou sozinho: escopo, diff e quais arquivos stagear.
Registro de riscos: o que montar antes de levar Claude Code para a equipe
Como montar um registro de riscos para levar Claude Code à equipe sem acidentes de permissão, CI e deploy. Com exemplos e código.