Advanced (Updated: 6/3/2026)

Claude Code Webhook Implementation: Signatures, Idempotency, Retries

Build production webhooks with Claude Code: raw bodies, signatures, idempotency, retries, tests, and runbooks.

Claude Code Webhook Implementation: Signatures, Idempotency, Retries

A webhook lets an external service notify your application over HTTP when something happens. Payments, GitHub pushes, form submissions, subscription changes, CRM updates, and SaaS status changes all fit this pattern.

The production trap is that a webhook endpoint looks tiny. In reality, it needs raw body handling, provider-specific signature verification, idempotency, an event store, a retry queue, tests, replay tooling, and an operations runbook. If you ask Claude Code only to “add a webhook endpoint”, you may get code that parses JSON before verification or processes duplicate deliveries twice.

Use this guide as a practical prompt and implementation checklist. For broader API structure, pair it with Claude Code API development, secrets management, security best practices, and queue systems.

Provider Contract

Write the provider contract before implementation. Claude Code works better when headers, IDs, response rules, and failure behavior are explicit.

ItemGitHub exampleStripe exampleImplementation point
EndpointPOST /webhooks/githubPOST /webhooks/stripeRouting
Event IDX-GitHub-Deliveryevent.idIdempotency key
Event typeX-GitHub-Eventevent.typeHandler dispatch
Signature headerX-Hub-Signature-256Stripe-SignatureSignature verification
Verification inputRaw bodyRaw bodyBody parser order
Success responseReturn 2xx quicklyReturn 2xx quicklyQueue before response

Use primary documentation for the exact rules: GitHub webhooks, GitHub delivery validation, Stripe webhooks, Stripe webhook signatures, Express express.raw, and Anthropic’s 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 Claude Code Clearly

Give Claude Code the operational requirements, not only the route name.

Implement a GitHub webhook receiver in Express + TypeScript.

Requirements:
- Add POST /webhooks/github
- Preserve the raw body on webhook routes with express.raw({ type: "*/*" })
- Parse JSON only after signature verification
- Verify X-Hub-Signature-256 with HMAC SHA-256
- Use X-GitHub-Delivery as the idempotency key
- Store each accepted event before processing
- Do not process the same delivery ID twice
- Return 202 quickly and process through a retry queue
- Add node:test coverage for valid signatures, invalid signatures, and duplicates
- Add a replay script for saved deliveries
- Read the secret from WEBHOOK_SECRET

Runnable Receiver

Create a small demo project:

npm init -y
npm install express
npm install -D typescript tsx @types/node @types/express

Create 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 === "ping") {
    console.log("GitHub ping received", event.id);
    return;
  }

  if (event.type === "push") {
    console.log("GitHub push received", event.id);
    return;
  }

  console.log("Webhook ignored", event.provider, event.type);
}

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}`);
  });
}

Run it:

WEBHOOK_SECRET=dev-secret-change-me npx tsx src/server.ts

Local Sender

Create 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());

Run it from another terminal:

WEBHOOK_SECRET=dev-secret-change-me npx tsx scripts/send-local-webhook.ts

Tests And Replay

Create test/webhook.test.ts:

import assert from "node:assert/strict";
import { AddressInfo } from "node:net";
import { beforeEach, test } from "node:test";
import {
  app,
  eventStore,
  processedEvents,
  retryQueue,
  signGitHubBody,
  verifyGitHubSignature,
} from "../src/server";

const secret = "dev-secret-change-me";

beforeEach(() => {
  eventStore.clear();
  processedEvents.clear();
  retryQueue.length = 0;
});

async function postWebhook(port: number, deliveryId: string, body: string) {
  return fetch(`http://127.0.0.1:${port}/webhooks/github`, {
    method: "POST",
    headers: {
      "content-type": "application/json",
      "x-github-event": "push",
      "x-github-delivery": deliveryId,
      "x-hub-signature-256": signGitHubBody(body, secret),
    },
    body,
  });
}

test("valid signature is accepted and stored once", async (t) => {
  const server = app.listen(0);
  t.after(() => server.close());

  const { port } = server.address() as AddressInfo;
  const body = JSON.stringify({ ref: "refs/heads/main" });

  const first = await postWebhook(port, "delivery-1", body);
  assert.equal(first.status, 202);
  assert.equal(eventStore.has("github:delivery-1"), true);

  const duplicate = await postWebhook(port, "delivery-1", body);
  assert.equal(duplicate.status, 202);
  assert.equal(eventStore.size, 1);
});

test("invalid signature is rejected", () => {
  const body = Buffer.from(JSON.stringify({ ok: true }));
  assert.equal(verifyGitHubSignature(body, "sha256=bad", secret), false);
});

Run the tests:

NODE_ENV=test npx tsx --test test/webhook.test.ts

Create scripts/replay-webhook.ts for saved deliveries:

import { readFile } from "node:fs/promises";

type SavedDelivery = {
  url: string;
  headers: Record<string, string>;
  body: string;
};

const file = process.argv[2];
if (!file) {
  console.error("Usage: npx tsx scripts/replay-webhook.ts delivery.json");
  process.exit(1);
}

const delivery = JSON.parse(await readFile(file, "utf8")) as SavedDelivery;
const response = await fetch(delivery.url, {
  method: "POST",
  headers: delivery.headers,
  body: delivery.body,
});

console.log(
  JSON.stringify(
    {
      status: response.status,
      responseBody: await response.text(),
    },
    null,
    2
  )
);

Practical Use Cases

Payment workflows use Stripe events to confirm orders, pause access after failed invoices, and send receipts. Development workflows use GitHub push and pull_request events to create previews, docs, and internal notifications. CRM and form workflows use external IDs to avoid duplicate tickets when providers retry. Outbound SaaS webhooks need the same discipline: signed payloads, delivery logs, timeouts, retries, and manual resend.

Pitfalls

The most common bug is parsing JSON before signature verification. HMAC signatures are calculated over the original bytes, so whitespace or encoding changes can break verification. The second bug is doing heavy work before returning 2xx, which triggers provider retries and increases duplicate risk. The third bug is generating a new idempotency key for every request instead of using the provider delivery ID. Finally, logging failures without storing the original delivery makes incident recovery slow.

In reviews, check the boring operational path: who can resend a failed delivery, where secret rotation is recorded, whether final failures alert someone, and whether support can answer “did we receive this event?” without opening production logs.

Rollout Checklist

  • Raw body is preserved on webhook routes only.
  • Signature failure returns 401; invalid JSON returns 400; accepted work returns 202.
  • Provider delivery ID is the idempotency key.
  • Event store keeps raw body, headers, status, attempts, and last error.
  • Retry policy has a cap and alerting for final failure.
  • Replay tooling works against saved deliveries.
  • Secrets are loaded from environment variables or a secret manager.
  • GitHub or Stripe test delivery has been run before production.

Summary

Production webhooks are small endpoints with security, reliability, and operations packed inside. Claude Code is useful here when the prompt includes the provider contract, raw body handling, signature verification, idempotency, retry queue, event store, tests, replay tooling, and rollout checklist from the start.

ClaudeCodeLab offers practical templates in Products and team training through Training for teams that want Claude Code to produce reviewable backend integrations instead of fragile demos.

#Claude Code #Webhook #API design #security #async processing
Free

Free PDF: Claude Code Cheatsheet

Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.

We handle your data with care and never send spam.

Level up your Claude Code workflow

Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.

Masa

About the Author

Masa

Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.