Build This Now
Build This Now
What Is Claude CodeInstallationNative InstallerFirst Project
speedy_devvkoen_salo
Blog/Handbook/Workflow/Failed Payment Recovery

Failed Payment Recovery

Build Stripe failed payment recovery end to end: an invoice.payment_failed webhook, a dunning email sequence running as a durable Inngest schedule with Resend, a card-update portal link, a grace period, and automatic entitlement downgrade.

Want the framework behind these builds?

Get the Claude Code system we use to plan, build, test, and ship production software.

See what we build for companies →
speedy_devvkoen_salo
speedy_devvWritten by speedy_devvPublished Jul 30, 202611 min readHandbook hubWorkflow index

Involuntary churn is the cheapest churn you will ever fix. A card expires, the charge declines, and a customer who still wants your product quietly disappears without ever deciding to leave. This guide builds the full recovery path in Next.js 16: a signature-verified invoice.payment_failed webhook, a dunning sequence running as a durable Inngest workflow with Resend emails, a card-update link that is still alive when the customer clicks it, a grace period, and an automatic downgrade when the money never arrives.

Stripe already retries the charge for you. Everything below is the layer Stripe does not own: the emails, the deadline, and what happens to access when the deadline passes.

The Recovery Path

Five moving parts, each with one job. Stripe handles the retries, your code handles the human and the entitlements.

StageTriggerWhat happens
Detectinvoice.payment_failedVerify signature, open a dunning record, start the workflow
RetryStripe Smart RetriesStripe re-attempts the charge on its own schedule
NotifyInngest sleep stepsThree Resend emails at day 0, day 4, and day 10
Recoverinvoice.paidCancel the workflow, close the record, restore access
DowngradeGrace period elapsedRe-check Stripe, drop the user to free, bust the cache

Turn on Smart Retries in the Stripe Dashboard under Billing, then Automatic collection, before you write any of this. Stripe's recommended default is 8 attempts over 2 weeks. Your grace period has to outlast that window or you will downgrade a customer while Stripe is still mid-retry.

The Dunning State Table

One row per failure episode, in Supabase. The partial unique index is the important part: it allows many historical rows per subscription but only one where the status is active, which is what stops repeat invoice.payment_failed events from spawning duplicate sequences.

Row Level Security is on, and only the owner can read their own row so a billing banner can render from the client without a service key.

create table public.dunning_states (
  id uuid primary key default gen_random_uuid(),
  user_id uuid not null references auth.users (id) on delete cascade,
  stripe_customer_id text not null,
  stripe_subscription_id text not null,
  stripe_invoice_id text not null,
  status text not null default 'active'
    check (status in ('active', 'recovered', 'downgraded')),
  attempt_count int not null default 0,
  grace_period_ends_at timestamptz not null,
  started_at timestamptz not null default now(),
  resolved_at timestamptz,
  updated_at timestamptz not null default now()
);

create unique index dunning_states_one_active_per_subscription
  on public.dunning_states (stripe_subscription_id)
  where status = 'active';

create index dunning_states_user_idx on public.dunning_states (user_id);

alter table public.dunning_states enable row level security;

create policy "read own dunning state"
  on public.dunning_states
  for select
  to authenticated
  using (auth.uid() = user_id);

Catching the Failure Webhook

The webhook route does the minimum: verify the signature against the raw bytes, pull the fields worth carrying, and hand off to Inngest. Anything slower than that risks a Stripe timeout, which turns into a retry and a second copy of the event.

One Stripe API change matters here. Since the Basil API versions, invoice.subscription no longer exists on the invoice object. The subscription id now lives under the invoice parent, so read it from invoice.parent.subscription_details.subscription and narrow the type before using it.

// app/api/stripe/webhook/route.ts
import { NextResponse } from "next/server";
import Stripe from "stripe";
import {
  inngest,
  invoicePaid,
  invoicePaymentFailed,
} from "@/lib/inngest/client";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(req: Request) {
  const signature = req.headers.get("stripe-signature");
  if (!signature) {
    return NextResponse.json({ error: "missing signature" }, { status: 400 });
  }

  const body = await req.text();

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!,
    );
  } catch {
    return NextResponse.json({ error: "invalid signature" }, { status: 400 });
  }

  if (event.type === "invoice.payment_failed" || event.type === "invoice.paid") {
    const invoice = event.data.object;
    const subscription = invoice.parent?.subscription_details?.subscription;

    if (
      invoice.id &&
      typeof invoice.customer === "string" &&
      typeof subscription === "string"
    ) {
      const payload = {
        stripeEventId: event.id,
        invoiceId: invoice.id,
        customerId: invoice.customer,
        subscriptionId: subscription,
        attemptCount: invoice.attempt_count,
        nextPaymentAttempt: invoice.next_payment_attempt,
        amountDue: invoice.amount_due,
        currency: invoice.currency,
      };

      await inngest.send(
        event.type === "invoice.paid"
          ? invoicePaid.create(payload)
          : invoicePaymentFailed.create(payload),
      );
    }
  }

  return NextResponse.json({ received: true });
}

next_payment_attempt is worth storing even though the workflow below does not branch on it. When it comes back null, Stripe has stopped retrying and that invoice is never getting collected automatically.

The Typed Inngest Client

Both events carry the same payload shape, so one type covers them. Declaring each event with eventType means a typo in an event name or a missing field fails at npx tsc --noEmit instead of at 2am in production.

// lib/inngest/client.ts
import { Inngest, eventType, staticSchema } from "inngest";

type InvoiceEventData = {
  stripeEventId: string;
  invoiceId: string;
  customerId: string;
  subscriptionId: string;
  attemptCount: number;
  nextPaymentAttempt: number | null;
  amountDue: number;
  currency: string;
};

export const invoicePaymentFailed = eventType("stripe/invoice.payment_failed", {
  schema: staticSchema<InvoiceEventData>(),
});

export const invoicePaid = eventType("stripe/invoice.paid", {
  schema: staticSchema<InvoiceEventData>(),
});

export const inngest = new Inngest({ id: "billing" });

staticSchema is compile-time only, which is all you need for a payload you construct yourself in the webhook route. Pass a Zod schema on that same option instead if you also want the payload validated at runtime.

The Card Update Link

Billing portal sessions expire quickly, so a URL generated when the email is sent is usually dead by the time someone opens it three days later. The fix is to email a link to your own route carrying a signed token, and mint the portal session on click.

The token is an HMAC over the customer id and an expiry, using Node's built-in crypto. No extra dependency, and timingSafeEqual keeps the comparison safe.

// lib/billing/recovery-link.ts
import { createHmac, timingSafeEqual } from "node:crypto";

const SECRET = process.env.BILLING_LINK_SECRET!;

function sign(payload: string): string {
  return createHmac("sha256", SECRET).update(payload).digest("base64url");
}

export function createRecoveryToken(customerId: string, ttlDays = 30): string {
  const payload = `${customerId}.${Date.now() + ttlDays * 86_400_000}`;
  const encoded = Buffer.from(payload).toString("base64url");
  return `${encoded}.${sign(payload)}`;
}

export function verifyRecoveryToken(token: string): string | null {
  const [encoded, signature] = token.split(".");
  if (!encoded || !signature) return null;

  const payload = Buffer.from(encoded, "base64url").toString("utf8");
  const expected = Buffer.from(sign(payload));
  const provided = Buffer.from(signature);

  if (expected.length !== provided.length) return null;
  if (!timingSafeEqual(expected, provided)) return null;

  const [customerId, expiresAt] = payload.split(".");
  if (!customerId || !expiresAt) return null;
  if (Number(expiresAt) < Date.now()) return null;

  return customerId;
}

The route verifies the token, then creates a portal session scoped to a single job. The payment_method_update flow drops the customer straight onto the card form instead of the portal homepage, and the after-completion redirect brings them back to your billing page.

// app/billing/fix-card/route.ts
import { redirect } from "next/navigation";
import Stripe from "stripe";
import { verifyRecoveryToken } from "@/lib/billing/recovery-link";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const APP_URL = process.env.NEXT_PUBLIC_APP_URL!;

export async function GET(req: Request) {
  const token = new URL(req.url).searchParams.get("token");
  const customerId = token ? verifyRecoveryToken(token) : null;

  if (!customerId) {
    redirect("/billing?error=expired_link");
  }

  const session = await stripe.billingPortal.sessions.create({
    customer: customerId,
    return_url: `${APP_URL}/billing`,
    flow_data: {
      type: "payment_method_update",
      after_completion: {
        type: "redirect",
        redirect: { return_url: `${APP_URL}/billing?card=updated` },
      },
    },
  });

  redirect(session.url);
}

The Dunning Emails

Three stages, escalating. The copy stays factual because most declines are an expired card rather than an empty account, and accusing a paying customer of not paying is how you turn involuntary churn into voluntary churn.

The Resend idempotency key is what makes this safe inside a retrying step. If the step fails after the send but before Inngest checkpoints the result, the retry reuses the key and Resend does not send a second copy.

// lib/email/dunning.ts
import { Resend } from "resend";
import { createServiceClient } from "@/lib/supabase/service";
import { createRecoveryToken } from "@/lib/billing/recovery-link";

const resend = new Resend(process.env.RESEND_API_KEY!);
const APP_URL = process.env.NEXT_PUBLIC_APP_URL!;

export type DunningStage = "first-failure" | "reminder" | "final-warning";

const COPY: Record<DunningStage, { subject: string; lead: string }> = {
  "first-failure": {
    subject: "Your payment did not go through",
    lead: "Your card was declined. Most declines clear as soon as the card on file is updated.",
  },
  reminder: {
    subject: "We still cannot charge your card",
    lead: "We tried again and the charge was declined. Your account is fully active while we keep retrying.",
  },
  "final-warning": {
    subject: "Final notice before your plan is downgraded",
    lead: "If the invoice is still unpaid when the grace period ends, your workspace moves to the free plan.",
  },
};

export async function sendDunningEmail(input: {
  userId: string;
  customerId: string;
  invoiceId: string;
  stage: DunningStage;
  amountDue: number;
  currency: string;
  graceEndsAt: string;
}) {
  const supabase = createServiceClient();

  const { data: profile } = await supabase
    .from("profiles")
    .select("email, full_name")
    .eq("id", input.userId)
    .maybeSingle();

  if (!profile?.email) {
    throw new Error(`No email on file for user ${input.userId}`);
  }

  const copy = COPY[input.stage];
  const token = createRecoveryToken(input.customerId);
  const amount = (input.amountDue / 100).toLocaleString("en-US", {
    style: "currency",
    currency: input.currency.toUpperCase(),
  });
  const deadline = new Date(input.graceEndsAt).toLocaleDateString("en-US");

  const { data, error } = await resend.emails.send(
    {
      from: "Billing <billing@yourdomain.com>",
      to: profile.email,
      subject: copy.subject,
      html: `
        <p>Hi ${profile.full_name ?? "there"},</p>
        <p>${copy.lead}</p>
        <p>Amount due: <strong>${amount}</strong></p>
        <p><a href="${APP_URL}/billing/fix-card?token=${token}">Update your card</a></p>
        <p>Your plan stays active until ${deadline}.</p>
      `,
    },
    { idempotencyKey: `dunning/${input.invoiceId}/${input.stage}` },
  );

  if (error) {
    throw new Error(`Resend rejected the dunning email: ${error.message}`);
  }

  return { emailId: data?.id };
}

The Dunning Sequence

This is the durable schedule. Each step.run is a checkpoint that retries independently, each step.sleep suspends the run without holding a process open, and cancelOn kills the whole thing the moment a paid invoice lands on that subscription.

The opening step is the duplicate guard. Stripe fires invoice.payment_failed on every retry, so the insert hits the partial unique index on the second and third failures and the run exits without sending anything.

// lib/inngest/functions/dunning.ts
import Stripe from "stripe";
import { inngest, invoicePaymentFailed } from "@/lib/inngest/client";
import { createServiceClient } from "@/lib/supabase/service";
import { sendDunningEmail, type DunningStage } from "@/lib/email/dunning";
import { downgradeToFree } from "@/lib/billing/entitlements";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const GRACE_PERIOD_DAYS = 21;

const NOTICES: ReadonlyArray<{ stage: DunningStage; delay: string | null }> = [
  { stage: "first-failure", delay: null },
  { stage: "reminder", delay: "4d" },
  { stage: "final-warning", delay: "6d" },
];

export const dunningSequence = inngest.createFunction(
  {
    id: "dunning-sequence",
    retries: 4,
    triggers: [invoicePaymentFailed],
    cancelOn: [{ event: "stripe/invoice.paid", match: "data.subscriptionId" }],
    concurrency: { key: "event.data.subscriptionId", limit: 1 },
  },
  async ({ event, step }) => {
    const { invoiceId, customerId, subscriptionId, attemptCount } = event.data;
    const { amountDue, currency } = event.data;

    const opened = await step.run("open-dunning-record", async () => {
      const supabase = createServiceClient();

      const { data: sub } = await supabase
        .from("subscriptions")
        .select("user_id")
        .eq("stripe_subscription_id", subscriptionId)
        .maybeSingle();

      if (!sub) return null;

      const graceEndsAt = new Date(
        Date.now() + GRACE_PERIOD_DAYS * 86_400_000,
      ).toISOString();

      const { error } = await supabase.from("dunning_states").insert({
        user_id: sub.user_id,
        stripe_customer_id: customerId,
        stripe_subscription_id: subscriptionId,
        stripe_invoice_id: invoiceId,
        attempt_count: attemptCount,
        grace_period_ends_at: graceEndsAt,
      });

      // 23505 is the partial unique index: a sequence is already running.
      if (error) {
        if (error.code === "23505") return null;
        throw error;
      }

      return { userId: sub.user_id as string, graceEndsAt };
    });

    if (!opened) {
      return { status: "already-running" };
    }

    for (const notice of NOTICES) {
      if (notice.delay) {
        await step.sleep(`wait-before-${notice.stage}`, notice.delay);
      }

      await step.run(`send-${notice.stage}`, () =>
        sendDunningEmail({
          userId: opened.userId,
          customerId,
          invoiceId,
          stage: notice.stage,
          amountDue,
          currency,
          graceEndsAt: opened.graceEndsAt,
        }),
      );
    }

    await step.sleepUntil("wait-out-grace-period", new Date(opened.graceEndsAt));

    return step.run("resolve-dunning", async () => {
      const invoice = await stripe.invoices.retrieve(invoiceId);
      const recovered = invoice.status === "paid";

      if (!recovered) {
        await downgradeToFree(opened.userId);
      }

      const supabase = createServiceClient();
      await supabase
        .from("dunning_states")
        .update({
          status: recovered ? "recovered" : "downgraded",
          resolved_at: new Date().toISOString(),
          updated_at: new Date().toISOString(),
        })
        .eq("stripe_invoice_id", invoiceId)
        .eq("status", "active");

      return { status: recovered ? "recovered" : "downgraded" };
    });
  },
);

The final step re-reads the invoice from Stripe before downgrading anyone. cancelOn is the fast path, but a dropped webhook should not cost a customer their plan, so the last action before revoking access is a direct read from the system of record.

Two details are specific to Inngest TypeScript SDK v4. Triggers now live inside the options object rather than in a separate second argument, and event types are declared with eventType next to the code that uses them instead of centrally through the EventSchemas class, which v4 removed. On v3 both of those look different.

Closing the Loop on Recovery

When the customer updates their card and Stripe collects, cancelOn stops the workflow between steps. It does not touch your database, so a second small function closes the record and clears the cached entitlement.

// lib/inngest/functions/recovered.ts
import { revalidateTag } from "next/cache";
import { inngest, invoicePaid } from "@/lib/inngest/client";
import { createServiceClient } from "@/lib/supabase/service";

export const closeRecoveredDunning = inngest.createFunction(
  {
    id: "close-recovered-dunning",
    retries: 4,
    triggers: [invoicePaid],
  },
  async ({ event, step }) => {
    const { subscriptionId } = event.data;

    return step.run("mark-recovered", async () => {
      const supabase = createServiceClient();

      const { data } = await supabase
        .from("dunning_states")
        .update({
          status: "recovered",
          resolved_at: new Date().toISOString(),
          updated_at: new Date().toISOString(),
        })
        .eq("stripe_subscription_id", subscriptionId)
        .eq("status", "active")
        .select("user_id")
        .maybeSingle();

      if (data) {
        revalidateTag(`entitlement:${data.user_id}`);
      }

      return { recovered: Boolean(data) };
    });
  },
);

revalidateTag is legal here because Inngest functions execute inside the serve route handler at app/api/inngest/route.ts, which is a Next.js route handler. Register both functions there.

// app/api/inngest/route.ts
import { serve } from "inngest/next";
import { inngest } from "@/lib/inngest/client";
import { dunningSequence } from "@/lib/inngest/functions/dunning";
import { closeRecoveredDunning } from "@/lib/inngest/functions/recovered";

export const { GET, POST, PUT } = serve({
  client: inngest,
  functions: [dunningSequence, closeRecoveredDunning],
});

Grace Period and Entitlements

Access has three states, not two. Active means paid. Grace means unpaid but still inside the window, which is the state where the customer keeps everything and sees a banner. Downgraded means the window closed.

The read is cached with the "use cache" directive and tagged per user, so the downgrade and the recovery both invalidate exactly one key rather than the whole billing surface.

// lib/billing/entitlements.ts
import { cacheLife, cacheTag, revalidateTag } from "next/cache";
import { createServiceClient } from "@/lib/supabase/service";

export type Entitlement = {
  tier: "free" | "pro";
  state: "active" | "grace" | "downgraded";
  graceEndsAt: string | null;
};

export async function getEntitlement(userId: string): Promise<Entitlement> {
  "use cache";
  cacheLife("minutes");
  cacheTag(`entitlement:${userId}`);

  const supabase = createServiceClient();

  const [{ data: sub }, { data: dunning }] = await Promise.all([
    supabase
      .from("subscriptions")
      .select("tier, status")
      .eq("user_id", userId)
      .maybeSingle(),
    supabase
      .from("dunning_states")
      .select("grace_period_ends_at")
      .eq("user_id", userId)
      .eq("status", "active")
      .maybeSingle(),
  ]);

  if (!sub || sub.tier === "free") {
    return { tier: "free", state: "downgraded", graceEndsAt: null };
  }

  if (dunning) {
    const open = new Date(dunning.grace_period_ends_at).getTime() > Date.now();
    return {
      tier: open ? "pro" : "free",
      state: open ? "grace" : "downgraded",
      graceEndsAt: dunning.grace_period_ends_at,
    };
  }

  return { tier: "pro", state: "active", graceEndsAt: null };
}

export async function downgradeToFree(userId: string): Promise<void> {
  const supabase = createServiceClient();

  const { error } = await supabase
    .from("subscriptions")
    .update({ tier: "free", updated_at: new Date().toISOString() })
    .eq("user_id", userId);

  if (error) throw error;

  revalidateTag(`entitlement:${userId}`);
}

The grace check inside the read is deliberate belt and braces. Even if the Inngest run is delayed, an expired grace window resolves to free on the very next page render.

Gating a page is then one call and one branch. A Server Component reads the entitlement, keeps full access during grace, and renders a banner pointing at the billing page.

// app/dashboard/page.tsx
import { getEntitlement } from "@/lib/billing/entitlements";
import { requireUser } from "@/lib/auth";

export default async function DashboardPage() {
  const user = await requireUser();
  const { state, graceEndsAt } = await getEntitlement(user.id);

  return (
    <main className="mx-auto max-w-4xl py-10">
      {state === "grace" && graceEndsAt && (
        <div className="mb-6 rounded-lg border border-amber-300 p-4 text-sm">
          We could not charge your card. Your plan stays active until{" "}
          {new Date(graceEndsAt).toLocaleDateString("en-US")}.
        </div>
      )}
      <h1 className="text-2xl font-semibold">Dashboard</h1>
    </main>
  );
}

Running It Locally

Three processes plus a trigger. The Inngest dev server discovers your functions from the serve route, the Stripe CLI forwards real events at your local webhook, and stripe trigger fires the failure on demand.

npm run dev
npx inngest-cli@latest dev
stripe listen --forward-to localhost:3000/api/stripe/webhook
stripe trigger invoice.payment_failed

Watch the run appear in the Inngest dev UI at port 8288. The sleep steps show as scheduled rather than blocking, which is the difference between a durable schedule and a setTimeout that dies with the process. To test cancellation without waiting days, shorten the delays to seconds, trigger a failure, then trigger invoice.paid for the same subscription and confirm the run shows as cancelled mid-sequence.

Failure Modes and What Prevents Each

Every guard in this build exists because the naive version breaks in a specific, expensive way. Worth reading the table before you simplify anything out.

What goes wrongWhy it happensWhat prevents it
Customer gets five identical emailsStripe fires the failure event on every retryPartial unique index plus early exit on conflict
Dead link in the emailPortal sessions expire long before the clickSigned token route that mints the session on click
Downgrade during an active retryGrace period shorter than the retry window21 day grace period, longer than a 2 week policy
Paid customer downgraded anywayA cancellation event was missedRe-read the invoice from Stripe before revoking
Stale access after recoveryCached entitlement never invalidatedTagged cache with revalidation on every write path
Duplicate email on a step retryStep failed after the send, before checkpointResend idempotency key scoped to invoice and stage

Briefing Claude Code

Claude writes this whole flow correctly when the constraints are in CLAUDE.md first. Without them it reaches for invoice.subscription, emails a raw portal URL, and starts a fresh sequence on every retry event.

## Billing Recovery Rules

- invoice.payment_failed fires on EVERY Stripe retry, not once. Guard with the
  partial unique index on dunning_states, exit early on Postgres error 23505.
- Read the subscription id from invoice.parent.subscription_details.subscription.
  invoice.subscription no longer exists on Basil API versions.
- Never email a Stripe portal URL. Email /billing/fix-card with a signed token
  and mint the portal session on click.
- Grace period must exceed the Smart Retry window. Re-read the invoice from
  Stripe before downgrading.
- Inngest SDK v4: triggers go inside the createFunction options object, and
  event types come from eventType() plus staticSchema(). EventSchemas is gone.
- Entitlement reads use "use cache" with cacheTag entitlement:{userId}.
  Every write path calls revalidateTag with the same key.

Quality Gates Before You Ship

Three commands, zero tolerance. The type check catches the Stripe invoice shape changes, the lint pass catches unused imports left behind by refactors, and the build catches a "use cache" function that accidentally reads request data.

npx tsc --noEmit
npx eslint .
npm run build

Ask Claude to run all three and fix what they surface before you consider the feature done.

claude "run tsc --noEmit, eslint, and the production build, then fix every error
and re-run until all three are clean"

Where the $29 Code Kit Fits

You can build every file above by hand with Claude Code, which needs a paid Anthropic plan to run at all. What the $29 Code Kit adds, one time and with no subscription, is the harness around that loop: it plans the recovery flow before writing it, builds it, evaluates the result against your schema and conventions, tests it against the running Inngest dev server and the Stripe CLI, and holds every change to the same gates used above (zero type errors, zero lint errors, a clean build) before anything counts as finished. It is not a different way to write dunning logic. It is the plan, build, evaluate, test discipline wired up so you stop running each step by hand.


Failed payment recovery is mostly patience encoded correctly. Stripe retries the card, your workflow sleeps between emails, the grace period gives an honest deadline, and the downgrade happens on its own when nothing arrives. Get the duplicate guard and the link expiry right and the rest is a sequence that runs for three weeks without anyone watching it.

Posted by @speedy_devv

Continue in Workflow

  • Agentic Commerce: How to Build an App AI Agents Can Pay For
    A plain-English guide to agentic commerce in 2026: what x402, ACP, and the Machine Payments Protocol do, plus a weekend walkthrough for shipping a paid API that AI agents can buy from.
  • Claude Code Best Practices
    Five habits separate engineers who ship with Claude Code: PRDs, modular CLAUDE.md rules, custom slash commands, /clear resets, and a system-evolution mindset.
  • Claude Code Auto Mode
    A second Sonnet model reviews every Claude Code tool call before it fires. What auto mode blocks, what it allows, and the allow rules it drops in your settings.
  • Channels, Routines, Teleport, Dispatch
    The four Claude Code features Anthropic shipped in March and April 2026 that turn the CLI into an event-driven coordination layer across phone, web, and desktop.
  • 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

  • Agent Fundamentals
    Five ways to build specialist agents in Claude Code: Task sub-agents, .claude/agents YAML, custom slash commands, CLAUDE.md personas, and perspective prompts.
  • Agent Harness Engineering
    The harness is every layer around your AI agent except the model itself. Learn the five control levers, the constraint paradox, and why harness design determines agent performance more than the model does.
  • Agent Patterns
    Orchestrator, fan-out, validation chain, specialist routing, progressive refinement, and watchdog. Six orchestration shapes to wire Claude Code sub-agents with.
  • Agent Teams Best Practices
    Battle-tested patterns for Claude Code Agent Teams. Context-rich spawn prompts, right-sized tasks, file ownership, delegate mode, and v2.1.33-v2.1.45 fixes.

Want the framework behind these builds?

Get the Claude Code system we use to plan, build, test, and ship production software.

See what we build for companies →
speedy_devvkoen_salo

On this page

The Recovery Path
The Dunning State Table
Catching the Failure Webhook
The Typed Inngest Client
The Card Update Link
The Dunning Emails
The Dunning Sequence
Closing the Loop on Recovery
Grace Period and Entitlements
Running It Locally
Failure Modes and What Prevents Each
Briefing Claude Code
Quality Gates Before You Ship
Where the $29 Code Kit Fits

Want the framework behind these builds?

Get the Claude Code system we use to plan, build, test, and ship production software.

See what we build for companies →