Build This Now
Build This Now
クロード・コードとは何か?Claude Code のインストールClaude Code ネイティブインストーラーClaude Code で最初のプロジェクトを作る
Subscription BillingMulti-Tenant SaaSAI Chat FeatureRate LimitingStripe WebhooksSemantic SearchRealtime UpdatesDrip Email Sequencesv2.1.122 Release NotesClaude Code の Dynamic Workflows:実際のコードベースで 1,000 個の subagents を動かす方法Claude Code ベストプラクティスClaude Opus 4.7 ベストプラクティスVPS上でのClaude CodeGit 統合Claude Code レビューClaude Code WorktreesClaude CodeリモートコントロールClaude Code ChannelsChannels、Routines、Teleport、DispatchClaude Code スケジュールタスクClaude Code権限管理Claude Code オートモードClaude Code で Stripe 決済を組み込むフィードバックループTodoワークフローClaude Code タスク管理プロジェクトテンプレートClaude Code の料金とトークン使用量Claude Code の料金:実際にいくら払うことになるのかClaude Code Ultra Review 完全ガイドClaude Code で Next.js アプリを作るSupabase DatabaseVercel DeepsecTest-Driven DevelopmentClaude Code で SaaS の MVP を作る方法Claude Code で認証を追加する(Supabase Auth)Claude Code でトランザクションメールを追加する(Resend + React Email)Claude Code で型安全な API を作る(oRPC + Zod)File UploadsBackground Jobs (Inngest)Admin DashboardFull-Text Searchエージェント型コマース:AI エージェントが支払えるアプリの作り方1M ContextUser API KeysAudit LogsCSV Import PipelineDatabase MigrationsProduction Error TrackingFeature FlagsGitHub ActionsHeadless ModeMax Plan vs APICaching and RevalidationIn-App NotificationsOutbound WebhooksPrompt CachingRoles & PermissionsMarketplace PaymentsUsage-Based Billing2026年、Claude Code で SaaS を作るといくらかかるかParallel AI AgentsCoding Agent Injection
speedy_devvkoen_salo
Blog/Handbook/Workflow/Outbound Webhooks

Outbound Webhooks

How to send webhooks to your customers from a Next.js 16 SaaS: endpoint registration, HMAC-signed payloads with timestamps, Inngest retries with exponential backoff, and a delivery log with manual replay.

設定をやめて、構築を始めよう。

AIオーケストレーション付きSaaSビルダーテンプレート。

企業向けに構築している実績を見る →
speedy_devvWritten by speedy_devvPublished Jul 21, 202611 min readHandbook hubWorkflow index

Problem: Your customers keep asking for webhooks. You ship a plain fetch() in your order handler, it times out once, the event is gone forever, and there is no record it ever happened. Nobody can tell you whether the payload was really yours or whether it arrived twice.

Fix: Treat outbound webhooks as a delivery system, not a function call. Customers register endpoints, every payload is HMAC-signed with a timestamp, Inngest retries failures with exponential backoff, every attempt lands in a delivery log you can replay by hand, and endpoints that stay broken get disabled automatically. This guide builds all of it in Next.js 16.

The Shape of the System

Four moving parts, and it helps to name them before writing code.

An endpoint is a customer-supplied URL plus a secret and a list of event types it wants. An event is something that happened in your product (order.created, invoice.paid). A delivery is one attempt to push one event to one endpoint, and it is the row you keep forever. A dispatcher is the durable job that does the HTTP call and decides whether to retry.

One event with three subscribed endpoints creates three deliveries. Each one succeeds or fails on its own. That separation is what makes replay and auto-disable possible later.

The Data Model

Two tables carry the whole feature. webhook_endpoints holds registration state and the failure counter. webhook_deliveries holds one row per attempted delivery, including the response you got back, so support questions have an answer.

Row level security keeps each account inside its own data. The dispatcher runs with the service role and bypasses these policies, which is what you want since it acts on behalf of the system rather than a signed-in user.

create table webhook_endpoints (
  id uuid primary key default gen_random_uuid(),
  account_id uuid not null references accounts(id) on delete cascade,
  url text not null,
  description text,
  event_types text[] not null default '{}',
  secret text not null,
  status text not null default 'active' check (status in ('active', 'disabled')),
  consecutive_failures integer not null default 0,
  disabled_at timestamptz,
  created_at timestamptz not null default now()
);

create table webhook_deliveries (
  id uuid primary key default gen_random_uuid(),
  endpoint_id uuid not null references webhook_endpoints(id) on delete cascade,
  account_id uuid not null references accounts(id) on delete cascade,
  event_type text not null,
  payload jsonb not null,
  status text not null default 'pending'
    check (status in ('pending', 'succeeded', 'failed')),
  attempts integer not null default 0,
  response_status integer,
  response_body text,
  duration_ms integer,
  error text,
  created_at timestamptz not null default now(),
  completed_at timestamptz
);

create index webhook_deliveries_endpoint_created_idx
  on webhook_deliveries (endpoint_id, created_at desc);

alter table webhook_endpoints enable row level security;
alter table webhook_deliveries enable row level security;

create policy "members read own endpoints" on webhook_endpoints
  for select using (account_id = (select auth.jwt() ->> 'account_id')::uuid);

create policy "members read own deliveries" on webhook_deliveries
  for select using (account_id = (select auth.jwt() ->> 'account_id')::uuid);

Store response_body truncated. A customer returning a 40 KB HTML error page on every delivery will bloat the table fast, so cap it at a couple of kilobytes in the dispatcher.

Generating a Signing Secret

Each endpoint gets its own secret. Per-endpoint (not per-account) means a customer can rotate one integration without breaking the others, and a leaked secret has a small blast radius.

The whsec_ prefix is a convention worth copying: it makes the string greppable in logs and scanners, and it tells the customer what they are looking at.

// lib/webhooks/secret.ts
import { randomBytes } from "node:crypto";

export function generateWebhookSecret(): string {
  return `whsec_${randomBytes(24).toString("base64url")}`;
}

Signing the Payload

The signature covers three things joined by dots: the delivery id, a unix timestamp, and the exact JSON body you are about to send. Including the timestamp is what makes replay attacks detectable, because the receiver can reject anything older than a few minutes. Including the delivery id gives the receiver a natural idempotency key.

Sign the string you actually transmit. If you sign an object and serialize it separately, key ordering will eventually differ and signatures will fail in ways that take a day to debug.

// lib/webhooks/sign.ts
import { createHmac, timingSafeEqual } from "node:crypto";

export function buildSignedPayload(
  deliveryId: string,
  timestamp: number,
  body: string,
): string {
  return `${deliveryId}.${timestamp}.${body}`;
}

export function signPayload(
  secret: string,
  deliveryId: string,
  timestamp: number,
  body: string,
): string {
  const signed = buildSignedPayload(deliveryId, timestamp, body);
  return createHmac("sha256", secret).update(signed).digest("hex");
}

export function verifySignature(
  expected: string,
  received: string,
): boolean {
  const a = Buffer.from(expected, "utf8");
  const b = Buffer.from(received, "utf8");
  if (a.length !== b.length) return false;
  return timingSafeEqual(a, b);
}

verifySignature is there for your own tests. As the sender you never call it in production, but a unit test that signs a fixed body and verifies it catches the day someone "cleans up" the separator format and silently breaks every customer.

timingSafeEqual throws when the buffers differ in length, which is why the length check comes first. Comparing with === leaks timing information about how many leading characters matched, and that is the whole attack.

The Code Your Customers Write

Publish this snippet in your docs. It is the receiving half of the same three headers, and shipping it alongside the feature removes most of the support load.

Note that it reads the raw request text before parsing. await request.json() re-serializes the body and the signature will not match.

// Example verifier for your customers (Next.js 16 route handler)
import { createHmac, timingSafeEqual } from "node:crypto";

const SECRET = process.env.WEBHOOK_SECRET!;
const TOLERANCE_SECONDS = 300;

export async function POST(request: Request) {
  const raw = await request.text();
  const id = request.headers.get("x-webhook-id");
  const timestamp = request.headers.get("x-webhook-timestamp");
  const signature = request.headers.get("x-webhook-signature");

  if (!id || !timestamp || !signature) {
    return new Response("Missing signature headers", { status: 400 });
  }

  const age = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (Number.isNaN(age) || age > TOLERANCE_SECONDS) {
    return new Response("Timestamp outside tolerance", { status: 400 });
  }

  const expected = createHmac("sha256", SECRET)
    .update(`${id}.${timestamp}.${raw}`)
    .digest("hex");
  const received = signature.replace(/^v1=/, "");

  const a = Buffer.from(expected, "utf8");
  const b = Buffer.from(received, "utf8");
  if (a.length !== b.length || !timingSafeEqual(a, b)) {
    return new Response("Invalid signature", { status: 401 });
  }

  const event = JSON.parse(raw);
  // `id` is your idempotency key: the same delivery can arrive twice.
  console.log("verified", event.type, id);
  return new Response("ok", { status: 200 });
}

Endpoint Registration

Registration is a Server Action with a Zod schema. Two validations matter beyond "is this a URL": force HTTPS, and reject hostnames that resolve inside your own network. Without that second check, a customer can register http://169.254.169.254/ and turn your dispatcher into a server-side request forgery tool against your cloud metadata service.

The secret is returned exactly once, at creation time. After that the UI shows a masked value and offers rotation, the same way every payment provider handles API keys.

// app/(dashboard)/webhooks/actions.ts
"use server";

import { revalidatePath } from "next/cache";
import { z } from "zod";
import { createClient } from "@/lib/supabase/server";
import { generateWebhookSecret } from "@/lib/webhooks/secret";

const BLOCKED_HOSTS = /^(localhost|127\.|10\.|192\.168\.|169\.254\.|::1)/i;

const EndpointInput = z.object({
  url: z
    .string()
    .url()
    .refine((value) => new URL(value).protocol === "https:", {
      message: "Endpoint must use HTTPS",
    })
    .refine((value) => !BLOCKED_HOSTS.test(new URL(value).hostname), {
      message: "Private and loopback addresses are not allowed",
    }),
  description: z.string().max(120).optional(),
  eventTypes: z.array(z.string()).min(1, "Pick at least one event type"),
});

export async function createEndpoint(_prev: unknown, formData: FormData) {
  const parsed = EndpointInput.safeParse({
    url: formData.get("url"),
    description: formData.get("description") || undefined,
    eventTypes: formData.getAll("eventTypes"),
  });

  if (!parsed.success) {
    return { ok: false as const, error: parsed.error.issues[0].message };
  }

  const supabase = await createClient();
  const { data: user } = await supabase.auth.getUser();
  if (!user.user) {
    return { ok: false as const, error: "Not signed in" };
  }

  const secret = generateWebhookSecret();

  const { data, error } = await supabase
    .from("webhook_endpoints")
    .insert({
      account_id: user.user.user_metadata.account_id,
      url: parsed.data.url,
      description: parsed.data.description,
      event_types: parsed.data.eventTypes,
      secret,
    })
    .select("id")
    .single();

  if (error) {
    return { ok: false as const, error: error.message };
  }

  revalidatePath("/webhooks");
  return { ok: true as const, id: data.id, secret };
}

The registration form is a Client Component using useActionState from React 19. It renders the secret once on success, with a copy button and a warning that it will not be shown again.

// app/(dashboard)/webhooks/endpoint-form.tsx
"use client";

import { useActionState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { createEndpoint } from "./actions";

const EVENT_TYPES = ["order.created", "order.refunded", "invoice.paid"] as const;

export function EndpointForm() {
  const [state, action, pending] = useActionState(createEndpoint, null);

  return (
    <form action={action} className="space-y-4 max-w-lg">
      <Input name="url" placeholder="https://api.customer.com/hooks" required />
      <Input name="description" placeholder="Production listener" />

      {EVENT_TYPES.map((type) => (
        <label key={type} className="flex items-center gap-2 text-sm">
          <Checkbox name="eventTypes" value={type} />
          {type}
        </label>
      ))}

      <Button type="submit" disabled={pending}>
        {pending ? "Creating..." : "Create endpoint"}
      </Button>

      {state?.ok === false && <p className="text-sm text-red-600">{state.error}</p>}

      {state?.ok === true && (
        <div className="rounded-md border p-3 text-sm">
          <p className="font-medium">Copy this secret now. It will not be shown again.</p>
          <code className="mt-2 block break-all">{state.secret}</code>
        </div>
      )}
    </form>
  );
}

Fanning Out to Subscribers

Two internal Inngest events drive everything. Add them to the EventSchemas map in your Inngest client so both the sends and the triggers are type-checked (the setup is covered in background jobs with Inngest).

// lib/inngest/client.ts (excerpt)
type Events = {
  "webhook/event.created": {
    data: { accountId: string; eventType: string; payload: Record<string, unknown> };
  };
  "webhook/delivery.requested": {
    data: { deliveryId: string; endpointId: string };
  };
};

When something happens in your product, you emit one webhook/event.created. A fan-out function looks up every active endpoint subscribed to that type, writes a delivery row for each, and queues one dispatch job per delivery.

Splitting fan-out from dispatch matters. A slow customer cannot block delivery to a fast one, and each delivery gets its own retry budget.

// lib/inngest/functions/fan-out-webhook-event.ts
import { inngest } from "@/lib/inngest/client";
import { createServiceClient } from "@/lib/supabase/service";

export const fanOutWebhookEvent = inngest.createFunction(
  { id: "fan-out-webhook-event", retries: 3 },
  { event: "webhook/event.created" },
  async ({ event, step }) => {
    const { accountId, eventType, payload } = event.data;

    const deliveries = await step.run("create-delivery-rows", async () => {
      const supabase = createServiceClient();

      const { data: endpoints, error } = await supabase
        .from("webhook_endpoints")
        .select("id")
        .eq("account_id", accountId)
        .eq("status", "active")
        .contains("event_types", [eventType]);

      if (error) throw new Error(`Endpoint lookup failed: ${error.message}`);
      if (!endpoints?.length) return [];

      const { data: rows, error: insertError } = await supabase
        .from("webhook_deliveries")
        .insert(
          endpoints.map((endpoint) => ({
            endpoint_id: endpoint.id,
            account_id: accountId,
            event_type: eventType,
            payload,
          })),
        )
        .select("id, endpoint_id");

      if (insertError) throw new Error(`Insert failed: ${insertError.message}`);
      return rows;
    });

    if (deliveries.length > 0) {
      await step.sendEvent(
        "queue-deliveries",
        deliveries.map((row) => ({
          name: "webhook/delivery.requested" as const,
          data: { deliveryId: row.id, endpointId: row.endpoint_id },
        })),
      );
    }

    return { queued: deliveries.length };
  },
);

step.sendEvent inside a step-aware function is durable. If the process dies after the rows are written but before the events are sent, Inngest replays from the checkpoint and the sends happen exactly once.

Retries With Exponential Backoff

Here is the dispatcher, and it is where most of the judgement lives. Inngest retries a failed step up to 4 times beyond the initial attempt by default, with exponential backoff and jitter. Raising retries to 5 gives six attempts spread over a few hours, which absorbs a customer's deploy window without becoming a nuisance.

The important part is classifying the response. A 5xx or a timeout is worth retrying because the customer's server may recover. A 404 or a 401 is not: the URL is wrong or the secret is wrong, and 10,000 more attempts will not fix either, so NonRetriableError stops the run immediately. A 429 gets RetryAfterError so you back off for exactly as long as the customer asked. Watch the units on that one: the Retry-After header is in seconds, RetryAfterError takes milliseconds, and passing the header straight through turns a 60 second back-off into 60 milliseconds.

// lib/inngest/functions/deliver-webhook.ts
import { NonRetriableError, RetryAfterError } from "inngest";
import { inngest } from "@/lib/inngest/client";
import { createServiceClient } from "@/lib/supabase/service";
import { signPayload } from "@/lib/webhooks/sign";

const TIMEOUT_MS = 10_000;

export const deliverWebhook = inngest.createFunction(
  {
    id: "deliver-webhook",
    retries: 5,
    concurrency: { key: "event.data.endpointId", limit: 5 },
    onFailure: async ({ event, error }) => {
      const { deliveryId } = event.data.event.data;
      const supabase = createServiceClient();

      const { data: delivery } = await supabase
        .from("webhook_deliveries")
        .update({
          status: "failed",
          error: error.message.slice(0, 500),
          completed_at: new Date().toISOString(),
        })
        .eq("id", deliveryId)
        .select("endpoint_id")
        .single();

      if (delivery) {
        await supabase.rpc("record_webhook_failure", {
          p_endpoint_id: delivery.endpoint_id,
          p_threshold: 15,
        });
      }
    },
  },
  { event: "webhook/delivery.requested" },
  async ({ event, step, attempt }) => {
    const { deliveryId } = event.data;
    const supabase = createServiceClient();

    const context = await step.run("load-delivery", async () => {
      const { data, error } = await supabase
        .from("webhook_deliveries")
        .select("id, event_type, payload, webhook_endpoints(id, url, secret, status)")
        .eq("id", deliveryId)
        .single();

      if (error || !data) {
        throw new NonRetriableError(`Delivery ${deliveryId} not found`);
      }

      const endpoint = data.webhook_endpoints as unknown as {
        id: string;
        url: string;
        secret: string;
        status: string;
      };

      if (endpoint.status !== "active") {
        throw new NonRetriableError("Endpoint is disabled");
      }

      return {
        endpointId: endpoint.id,
        url: endpoint.url,
        secret: endpoint.secret,
        body: JSON.stringify({
          id: data.id,
          type: data.event_type,
          created_at: new Date().toISOString(),
          data: data.payload,
        }),
      };
    });

    await step.run("post-to-endpoint", async () => {
      const timestamp = Math.floor(Date.now() / 1000);
      const signature = signPayload(
        context.secret,
        deliveryId,
        timestamp,
        context.body,
      );
      const startedAt = Date.now();

      const response = await fetch(context.url, {
        method: "POST",
        headers: {
          "content-type": "application/json",
          "user-agent": "YourApp-Webhooks/1.0",
          "x-webhook-id": deliveryId,
          "x-webhook-timestamp": String(timestamp),
          "x-webhook-signature": `v1=${signature}`,
          "x-webhook-attempt": String(attempt + 1),
        },
        body: context.body,
        signal: AbortSignal.timeout(TIMEOUT_MS),
      });

      const text = (await response.text()).slice(0, 2_000);
      const durationMs = Date.now() - startedAt;

      await supabase
        .from("webhook_deliveries")
        .update({
          attempts: attempt + 1,
          response_status: response.status,
          response_body: text,
          duration_ms: durationMs,
        })
        .eq("id", deliveryId);

      if (response.status === 429) {
        const header = Number(response.headers.get("retry-after"));
        const retryAfterSeconds = Number.isFinite(header) && header > 0 ? header : 60;
        // Retry-After is in seconds, RetryAfterError takes milliseconds.
        throw new RetryAfterError(
          "Endpoint rate limited us",
          retryAfterSeconds * 1000,
        );
      }

      if (response.status >= 400 && response.status < 500 && response.status !== 408) {
        throw new NonRetriableError(
          `Endpoint rejected the payload with ${response.status}`,
        );
      }

      if (!response.ok) {
        throw new Error(`Endpoint returned ${response.status}`);
      }

      return { status: response.status, durationMs };
    });

    await step.run("mark-succeeded", async () => {
      await supabase
        .from("webhook_deliveries")
        .update({ status: "succeeded", completed_at: new Date().toISOString() })
        .eq("id", deliveryId);

      await supabase
        .from("webhook_endpoints")
        .update({ consecutive_failures: 0 })
        .eq("id", context.endpointId);
    });
  },
);

Three details worth calling out. attempt is zero-indexed, so attempt + 1 is the human-readable attempt number you write to the log and send in x-webhook-attempt. The concurrency key is the endpoint id, so no single customer ever gets more than five simultaneous requests from you no matter how much traffic their account generates. And the success step resets consecutive_failures to zero, which is what keeps a customer with a five-minute outage from ever being auto-disabled.

Auto-Disable After Repeated Failures

The failure counter has to be incremented atomically. Two deliveries failing at the same moment would otherwise read the same value, write the same value, and lose a failure. A Postgres function does the read, the increment, and the conditional disable in one statement. It runs security definer so the caller does not need write access to the endpoints table, which makes the set search_path = '' line mandatory rather than optional: without it, anyone who can influence the session search path can shadow public.webhook_endpoints with their own table and hijack a function running with elevated privileges. That is also why every reference inside the body is schema-qualified.

create or replace function record_webhook_failure(
  p_endpoint_id uuid,
  p_threshold integer default 15
)
returns integer
language plpgsql
security definer
set search_path = ''
as $$
declare
  new_count integer;
begin
  update public.webhook_endpoints
  set
    consecutive_failures = consecutive_failures + 1,
    status = case
      when consecutive_failures + 1 >= p_threshold then 'disabled'
      else status
    end,
    disabled_at = case
      when consecutive_failures + 1 >= p_threshold then now()
      else disabled_at
    end
  where id = p_endpoint_id
  returning consecutive_failures into new_count;

  return new_count;
end;
$$;

Disabling silently is hostile. When the threshold trips, email the account owner through Resend with a link back to the endpoint page so they can fix the URL and re-enable it themselves. Send that from its own Inngest function triggered on a webhook/endpoint.disabled event, so a failing mail provider never interferes with the disable itself.

The Delivery Log

The log is a Server Component. params and searchParams are Promises in Next.js 16, so both get awaited. This data is live and per-account, so it stays uncached.

// app/(dashboard)/webhooks/[endpointId]/page.tsx
import { createClient } from "@/lib/supabase/server";
import { ReplayButton } from "./replay-button";

interface PageProps {
  params: Promise<{ endpointId: string }>;
  searchParams: Promise<{ status?: string }>;
}

export default async function DeliveryLogPage({ params, searchParams }: PageProps) {
  const { endpointId } = await params;
  const { status } = await searchParams;

  const supabase = await createClient();
  let query = supabase
    .from("webhook_deliveries")
    .select("id, event_type, status, attempts, response_status, duration_ms, created_at")
    .eq("endpoint_id", endpointId)
    .order("created_at", { ascending: false })
    .limit(100);

  if (status) query = query.eq("status", status);

  const { data: deliveries } = await query;

  return (
    <main className="p-8">
      <h1 className="text-2xl font-bold mb-6">Delivery log</h1>
      <table className="w-full text-sm">
        <tbody>
          {deliveries?.map((delivery) => (
            <tr key={delivery.id} className="border-t">
              <td className="py-2 font-mono">{delivery.event_type}</td>
              <td>{delivery.status}</td>
              <td>{delivery.response_status ?? "-"}</td>
              <td>{delivery.attempts} tries</td>
              <td>{delivery.duration_ms ? `${delivery.duration_ms}ms` : "-"}</td>
              <td className="text-right">
                <ReplayButton deliveryId={delivery.id} />
              </td>
            </tr>
          ))}
        </tbody>
      </table>
    </main>
  );
}

The list of event types you publish barely changes, so it is a good candidate for the "use cache" directive with a long cache life. Every dashboard page and docs page that renders the catalogue then shares one cached result.

// lib/webhooks/catalog.ts
import { cacheLife } from "next/cache";

export async function getEventCatalog() {
  "use cache";
  cacheLife("days");

  return [
    { type: "order.created", description: "A new order was placed" },
    { type: "order.refunded", description: "An order was fully refunded" },
    { type: "invoice.paid", description: "An invoice was paid in full" },
  ];
}

Manual Replay

Replay clones the original payload into a fresh delivery row and queues it. It never mutates the old row, so the log stays an accurate history of what actually happened.

// app/(dashboard)/webhooks/[endpointId]/replay.ts
"use server";

import { revalidatePath } from "next/cache";
import { createClient } from "@/lib/supabase/server";
import { inngest } from "@/lib/inngest/client";

export async function replayDelivery(deliveryId: string) {
  const supabase = await createClient();

  const { data: original, error } = await supabase
    .from("webhook_deliveries")
    .select("endpoint_id, account_id, event_type, payload")
    .eq("id", deliveryId)
    .single();

  if (error || !original) {
    return { ok: false as const, error: "Delivery not found" };
  }

  const { data: replay, error: insertError } = await supabase
    .from("webhook_deliveries")
    .insert({
      endpoint_id: original.endpoint_id,
      account_id: original.account_id,
      event_type: original.event_type,
      payload: original.payload,
    })
    .select("id")
    .single();

  if (insertError) {
    return { ok: false as const, error: insertError.message };
  }

  await inngest.send({
    name: "webhook/delivery.requested",
    data: { deliveryId: replay.id, endpointId: original.endpoint_id },
  });

  revalidatePath(`/webhooks/${original.endpoint_id}`);
  return { ok: true as const, id: replay.id };
}

RLS on the select is doing real work here. Because the read runs with the signed-in user's client, one account cannot replay another account's delivery by guessing a UUID.

The button that calls it is a small Client Component, so the row can show a pending state without turning the whole log into a Client Component.

// app/(dashboard)/webhooks/[endpointId]/replay-button.tsx
"use client";

import { useTransition } from "react";
import { Button } from "@/components/ui/button";
import { replayDelivery } from "./replay";

export function ReplayButton({ deliveryId }: { deliveryId: string }) {
  const [pending, startTransition] = useTransition();

  return (
    <Button
      variant="ghost"
      size="sm"
      disabled={pending}
      onClick={() => startTransition(() => void replayDelivery(deliveryId))}
    >
      {pending ? "Replaying..." : "Replay"}
    </Button>
  );
}

Briefing Claude Code

Claude will happily write a fetch() in a loop if you ask for "webhooks". Give it the rules first. Drop this into CLAUDE.md so every session in the repo starts with the same constraints.

## Outbound Webhooks

- Signature is HMAC-SHA256 over `${deliveryId}.${timestamp}.${rawBody}`, sent as
  x-webhook-id, x-webhook-timestamp, x-webhook-signature (v1= prefix).
- Never re-serialize the body between signing and sending.
- Delivery happens only in lib/inngest/functions/deliver-webhook.ts. No inline fetch.
- 4xx (except 408 and 429) throws NonRetriableError. 429 throws RetryAfterError.
  5xx and timeouts throw plain Error so Inngest retries.
- Every attempt writes to webhook_deliveries. Truncate response bodies at 2000 chars.
- consecutive_failures resets to 0 on success and is incremented via the
  record_webhook_failure RPC, never with a read-then-write.

Then work in plan mode, since this feature touches a schema, two jobs, a Server Action, and a page:

claude --plan "add outbound webhooks: endpoint registration with per-endpoint
secrets, HMAC-signed delivery through Inngest with retries, a delivery log page,
and manual replay. Follow the Outbound Webhooks rules in CLAUDE.md."

Testing It Before You Ship

Run the Inngest dev server next to your app so you can watch each attempt, its backoff, and its payload.

npx inngest-cli@latest dev

Point an endpoint at a URL you control that returns a 500 and confirm the run retries on a widening interval rather than immediately. Then flip it to a 404 and confirm the run stops after one attempt. Then verify the signature end to end by running your published verifier against a real delivery, because a signing scheme that is only tested against itself will pass every test and still fail for customers.

Finish with the gates:

npx tsc --noEmit
npm run lint
npm run build

Where the Harness Fits

A feature like this is five files that have to agree with each other, and a single Claude Code session will usually get four of them right. The $29 Code Kit ($29 one-time, no subscription) is a harness that sits on top of Claude Code and makes the fifth one reliable: it plans the feature before any file is touched, builds it, evaluates the output against the plan, tests it, and runs the quality gates so what lands has zero type errors, zero lint errors, and a clean build. Claude Code itself still needs a paid Anthropic plan, and the Kit does not replace it. It just stops the pipeline from handing you a signature helper that nobody calls.


Webhooks are a promise to your customers that their systems will find out what happened in yours. Sign every payload, log every attempt, retry with backoff, and let people replay when their side was down. The delivery log is the part they will thank you for.

Posted by @speedy_devv

Continue in Workflow

  • エージェント型コマース:AI エージェントが支払えるアプリの作り方
    2026年のエージェント型コマースをわかりやすく解説するガイド。x402、ACP、Machine Payments Protocol が何をするのか、そして AI エージェントが購入できる有料 API を週末で出荷するための手順を紹介します。
  • Claude Code ベストプラクティス
    Claude Codeで成果を出すエンジニアを分ける5つの習慣: PRD、モジュラーなCLAUDE.mdのルール、カスタムスラッシュコマンド、/clearリセット、そしてシステム進化の思考法。
  • Claude Code オートモード
    2つ目の Sonnet モデルが、Claude Code のすべてのツール呼び出しを実行前に審査します。オートモードがブロックするもの・許可するもの、そして settings.json に追加される許可ルールについて解説します。
  • Channels、Routines、Teleport、Dispatch
    Anthropic が2026年3月と4月に出荷した4つの Claude Code 機能。これらは CLI を、スマホ・ウェブ・デスクトップをまたぐイベント駆動の調整レイヤーに変えます。
  • Claude Code 1M Context in Practice: When Bigger Isn't Better
    The 1M-token context window is GA at flat pricing, but bigger isn't always better. A decision framework, token-cost math, and when to use /compact, subagents, and dynamic workflows instead.
  • How to Build an Admin Dashboard With Claude Code
    Ship an internal admin panel with Claude Code: role-gated routes, a searchable users and orders table with pagination, impersonation-safe RLS, and metrics tiles pulled through a type-safe API.

More from Handbook

  • エージェントの基礎
    Claude Codeでスペシャリストエージェントを構築する5つの方法:タスクサブエージェント、.claude/agents YAML、カスタムスラッシュコマンド、CLAUDE.mdペルソナ、パースペクティブプロンプト。
  • エージェント・ハーネス・エンジニアリング
    ハーネスとは、AIエージェントを構成するモデル以外のすべての層のことです。5つの制御レバー、制約のパラドックス、そしてなぜハーネス設計がモデルよりもエージェントのパフォーマンスを左右するのかを学びましょう。
  • エージェントパターン
    オーケストレーター、ファンアウト、バリデーションチェーン、スペシャリストルーティング、プログレッシブリファインメント、ウォッチドッグ。Claude Code のサブエージェントを組み合わせる6つのオーケストレーション形状。
  • エージェントチームのベストプラクティス
    Claude Code エージェントチームの実証済みパターン。コンテキストが豊富なスポーンプロンプト、適切なサイズのタスク、ファイルオーナーシップ、デリゲートモード、v2.1.33〜v2.1.45 の修正内容。

設定をやめて、構築を始めよう。

AIオーケストレーション付きSaaSビルダーテンプレート。

企業向けに構築している実績を見る →

In-App Notifications

Build a full notification system for your SaaS: a notifications table with RLS, Inngest fan-out on events, a realtime unread badge over Supabase channels, per-user notification preferences, and a daily email digest through Resend.

Prompt Caching

Claude Code prompt caching is automatic and bills cached tokens at ~10% of normal input. Here's how to stop leaking the 90% discount, with real cost math.

On this page

The Shape of the System
The Data Model
Generating a Signing Secret
Signing the Payload
The Code Your Customers Write
Endpoint Registration
Fanning Out to Subscribers
Retries With Exponential Backoff
Auto-Disable After Repeated Failures
The Delivery Log
Manual Replay
Briefing Claude Code
Testing It Before You Ship
Where the Harness Fits

設定をやめて、構築を始めよう。

AIオーケストレーション付きSaaSビルダーテンプレート。

企業向けに構築している実績を見る →