Implementasi Webhook dengan Claude Code: signature, idempotency, dan retry
Bangun webhook produksi dengan Claude Code: raw body, verifikasi signature, idempotency, retry, test, dan runbook.
Webhook adalah cara layanan eksternal mengirim notifikasi HTTP ke aplikasi Anda saat suatu event terjadi. Pembayaran selesai, GitHub push, form terkirim, perubahan langganan, update CRM, dan perubahan status SaaS sering memakai pola ini.
Di production, webhook bukan sekadar endpoint POST yang membaca JSON. Anda perlu menjaga raw body, memverifikasi signature dari provider, memakai idempotency key agar event yang dikirim ulang tidak diproses dua kali, menyimpan event sebelum diproses, memindahkan pekerjaan berat ke retry queue, dan menyiapkan replay tool.
Jika Anda hanya meminta Claude Code “buat webhook”, hasilnya bisa berupa demo yang terlihat jalan tetapi rapuh: JSON diparse sebelum signature diverifikasi, duplicate delivery diproses dua kali, atau failure hanya muncul di log tanpa cara replay. Untuk fondasi API, baca juga Claude Code API development, secrets management, security best practices, dan queue system.
Kontrak provider
| Item | Contoh GitHub | Contoh Stripe | Fokus implementasi |
|---|---|---|---|
| Endpoint | POST /webhooks/github | POST /webhooks/stripe | Route terpisah |
| Event ID | X-GitHub-Delivery | event.id | Idempotency key |
| Event type | X-GitHub-Event | event.type | Pemilihan handler |
| Signature header | X-Hub-Signature-256 | Stripe-Signature | Verifikasi asal |
| Input verifikasi | raw body | raw body | Urutan body parser |
| Respons sukses | 2xx cepat | 2xx cepat | Simpan lalu queue |
Pakai sumber resmi: GitHub Webhooks, GitHub delivery validation, Stripe Webhooks, Stripe webhook signatures, Express express.raw, dan 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 untuk Claude Code
Implementasikan GitHub Webhook receiver dengan Express + TypeScript.
Requirements:
- Tambahkan POST /webhooks/github
- Pertahankan raw body dengan express.raw({ type: "*/*" }) pada route webhook
- Parse JSON hanya setelah signature verification
- Verifikasi X-Hub-Signature-256 dengan HMAC SHA-256
- Gunakan X-GitHub-Delivery sebagai idempotency key
- Simpan setiap event yang diterima sebelum processing
- Jangan proses delivery id yang sama dua kali
- Return 202 dengan cepat dan proses pekerjaan berat lewat retry queue
- Tambahkan node:test untuk signature valid, signature invalid, dan duplicate delivery
- Tambahkan replay script untuk delivery yang disimpan
- Baca secret dari WEBHOOK_SECRET
Receiver yang bisa dijalankan
npm init -y
npm install express
npm install -D typescript tsx @types/node @types/express
Buat 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
Local sender dan test
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
Untuk replay, simpan url, headers, dan body tanpa memformat ulang body. Signature HMAC dihitung dari byte asli.
Use case konkret
Pada pembayaran, event Stripe dapat mengonfirmasi order, menghentikan akses setelah invoice gagal, dan mengirim receipt. Pada workflow development, GitHub push dan pull_request dapat membuat preview, dokumentasi, dan notifikasi internal. Pada form dan CRM, external ID mencegah ticket ganda saat provider retry. Untuk webhook keluar dari SaaS Anda sendiri, siapkan signed payload, delivery log, timeout, retry, dan manual resend.
Kesalahan umum
Kesalahan pertama adalah parse JSON sebelum signature verification. Kedua, menjalankan pekerjaan berat sebelum return 2xx, sehingga provider mengirim ulang. Ketiga, membuat idempotency key baru untuk setiap request. Keempat, hanya mengandalkan log tanpa menyimpan delivery asli. Hindari juga menulis secret atau payload lengkap ke log.
Checklist production
- Raw body hanya dipertahankan pada route webhook.
- Signature invalid return
401; JSON invalid return400; event diterima return202. - Delivery ID dari provider menjadi idempotency key.
- Event store menyimpan raw body, headers, status, attempts, dan last error.
- Retry queue memiliki limit, backoff, dan alert untuk final failure.
- Replay script bekerja dengan delivery yang disimpan.
- Secret dibaca dari environment variable atau secret manager.
Ringkasan
Webhook production memuat keamanan, reliability, dan operasi di dalam endpoint kecil. Claude Code lebih berguna jika prompt sejak awal memuat kontrak provider, raw body, signature, idempotency, queue, tests, replay, dan runbook.
ClaudeCodeLab menyediakan template di Products dan training tim di Training. Dari uji praktik, menulis test raw body dan idempotency terlebih dahulu membuat output Claude Code jauh lebih stabil.
PDF gratis: cheatsheet Claude Code
Masukkan email dan unduh satu halaman berisi command, kebiasaan review, dan workflow aman.
Kami menjaga datamu dan tidak mengirim spam.
Tentang penulis
Masa
Engineer yang berfokus pada workflow Claude Code praktis dan adopsi tim.
Artikel terkait
Permission receipt Claude Code: mencatat scope, bukti, dan rollback
Pola permission receipt untuk Claude Code: aksi yang diizinkan, batas approval, command verifikasi, rollback, dan cek CTA revenue.
Agent Harness Aman untuk Claude Code dan Codex: Permission, Verifikasi, dan Rollback
Rancang Agent Harness praktis untuk Claude Code dan Codex dengan policy, plan, verification, dan recovery layer.
Subagent Claude Code: panduan praktis untuk delegasi artikel dan kode
Panduan subagent Claude Code untuk membagi pekerjaan artikel dan kode: aturan delegasi, prompt, risiko, dan checklist.