私が運営している小さな SaaS で、ある月だけ MRR がガクッと落ちたことがありました。新規獲得は伸びていたのに、です。原因を Stripe Dashboard で追いかけていくと、答えはとても地味でした。カードの有効期限切れと、決済ネットワーク側の一時拒否。これだけでその月の解約の 6 割が説明できてしまったのです。
機能改善でもキャンペーンでもなく、「失敗した課金をどう回収するか」が、自分の SaaS の生存ラインに直結しています。当時その事実を突きつけられて、私はかなり落ち込みました。同時に、ここを自動化できれば、新規獲得と同じくらいの効果がありそうだとも思いました。
ここではその経験を経て私が組み上げた Stripe Dunning(請求リカバリ)パイプラインを、Antigravity の AI エージェントを軸にして再構築する方法を共有します。失敗イベントの捕捉から、リトライ制御、文面の出し分け、Slack 通知、最後の解約防止オファーまで、本番で動いているパターンを丁寧に書き起こしました。
なぜ「Dunning」を AI エージェントに任せたほうがいい理由
「Dunning(ダニング)」は、決済失敗後に支払いを促す一連のコミュニケーションのことです。日本語ではあまり馴染みがない言葉ですが、海外 SaaS では Stripe や Chargebee など多くのプラットフォームに専用機能があるくらい重要視されています。
ところがこの領域、見た目以上に判断が複雑です。
- カード有効期限切れと、残高不足と、3DS 認証失敗では、送るべき文面と送るべきタイミングが違う
- リトライ回数を増やすほど回収率は上がるが、過剰だと顧客体験を壊す
- 同じ顧客が短期間で複数の失敗を起こしたとき、メールを送り続けるのは逆効果
- 法人カードと個人カードでは「誰に通知すべきか」も変わる
これを Webhook ハンドラの中に if 文で書き始めると、すぐに保守不能になります。私も最初はそうしました。150 行を超えたあたりで自分でも何が起きているか追えなくなり、一度すべて捨てました。
そこで考え方を変えて、ロジックの分岐を AI エージェントに渡すことにしました。Antigravity 上の Manager Surface に dunning 用のサブエージェントを定義し、Webhook ハンドラはイベントを正規化してエージェントに渡すだけ、という設計です。
全体アーキテクチャ
完成形は次のような流れになります。
- Stripe Webhook 受信(
invoice.payment_failed/customer.subscription.updated等) - 冪等性チェック & イベント正規化(Cloudflare D1 または Postgres)
- Dunning Agent への委譲(失敗理由・顧客プロフィール・過去のリトライ履歴を渡す)
- 判断: 「リトライを Stripe に任せるか/カスタマーへ通知するか/解約防止オファーを発火するか」
- アクション実行: Resend でメール、Slack で内部通知、KV/DB で状態更新
- 観測: 全イベントを構造化ログ化し、Looker Studio で可視化
このうち 3 と 4 を AI エージェントに任せるのがポイントです。Stripe 側にもデフォルトの Smart Retries はありますが、こちらは自社のドメインルール(無料体験中は通知を控える、年額プランは特別扱いする、過去 1 年で解約復帰した顧客には別文面、など)を載せられる場所が少ありません。エージェント側にプロンプトとツールで明示することで、ロジックの言語化と更新が一気に楽になります。
ステップ 1: Webhook を冪等に受ける
最初のコードは Webhook ハンドラです。Stripe からは同じイベントが複数回飛んでくる可能性があるので、必ず冪等性キーで二重処理を防ぎます。
// app/api/webhook/stripe-billing/route.ts
import Stripe from "stripe";
import { getCloudflareContext } from "@opennextjs/cloudflare";
// 事前に Cloudflare Worker のシークレットとして設定:
// STRIPE_SECRET_KEY=sk_live_...(本番)/ sk_test_...(テスト)
// STRIPE_WEBHOOK_BILLING_SECRET=whsec_...
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2025-08-27.basil",
});
export async function POST(req: Request) {
const sig = req.headers.get("stripe-signature");
if (!sig) return new Response("missing signature", { status: 400 });
const body = await req.text();
let event: Stripe.Event;
try {
event = await stripe.webhooks.constructEventAsync(
body,
sig,
process.env.STRIPE_WEBHOOK_BILLING_SECRET!,
undefined,
Stripe.createSubtleCryptoProvider() // Cloudflare Workers では必須
);
} catch (err) {
console.error("webhook signature verification failed", err);
return new Response("invalid signature", { status: 400 });
}
// 冪等性: event.id を一意キーに使う
const { env } = getCloudflareContext();
const seen = await env.BILLING_KV.get(`evt:${event.id}`);
if (seen) {
return new Response("duplicate", { status: 200 });
}
await env.BILLING_KV.put(`evt:${event.id}`, "1", { expirationTtl: 60 * 60 * 24 * 7 });
// 課金失敗だけを Dunning パイプラインに流す
if (event.type === "invoice.payment_failed") {
await enqueueDunning(event.data.object as Stripe.Invoice, env);
}
return new Response("ok", { status: 200 });
}ここで重要なのは「Webhook ハンドラ自体には判断ロジックを置かない」ことです。Stripe は再送リトライを 3 日間続けるので、ハンドラが重くなるとそのたびにタイムアウトとリトライが連鎖します。受信→冪等性チェック→キューイング、ここまでで 100 ms 以内に返すのを目標にしてください。
期待する動作: 同じ evt_xxx が 5 回飛んでも、enqueueDunning は 1 度しか呼ばれません。
ステップ 2: 失敗理由を正規化して文脈を作る
Stripe の Invoice オブジェクトはとにかく情報量が多く、そのまま AI エージェントに渡しても判断材料になりません。私は次のような正規化レイヤーを噛ませています。
// lib/dunning/normalize.ts
export type DunningContext = {
customerId: string;
customerEmail: string;
amountDue: number; // 最小単位 (JPY ならそのまま、USD なら cents)
currency: string;
failureReason: "card_expired" | "insufficient_funds" | "authentication_required" | "do_not_honor" | "unknown";
attemptCount: number; // Stripe の next_payment_attempt 回目
planTier: "basic" | "pro" | "team";
isAnnual: boolean;
customerSegment: "trial" | "new" | "loyal" | "winback";
lastSuccessfulPaymentAt: number | null; // unix秒
totalLifetimeValueJpy: number;
};
export async function buildDunningContext(invoice: Stripe.Invoice, env: Env): Promise<DunningContext> {
const customer = await stripe.customers.retrieve(invoice.customer as string);
const subs = await stripe.subscriptions.list({ customer: invoice.customer as string, limit: 1 });
const sub = subs.data[0];
// Stripe の decline_code → 自社ドメイン語彙へ
const code = invoice.last_finalization_error?.decline_code ?? invoice.last_payment_error?.decline_code;
const failureReason = mapDeclineCode(code);
// 失敗理由が分からないとエージェントが過剰反応するので必ず "unknown" にフォールバック
if (!failureReason) {
console.warn("unmapped decline_code", code, "invoice", invoice.id);
}
return {
customerId: invoice.customer as string,
customerEmail: (customer as Stripe.Customer).email ?? "",
amountDue: invoice.amount_due,
currency: invoice.currency,
failureReason: failureReason ?? "unknown",
attemptCount: invoice.attempt_count,
planTier: detectPlanTier(sub),
isAnnual: sub.items.data[0].price.recurring?.interval === "year",
customerSegment: await detectSegment(invoice.customer as string, env),
lastSuccessfulPaymentAt: await getLastSuccessfulPaymentAt(invoice.customer as string, env),
totalLifetimeValueJpy: await getLtvJpy(invoice.customer as string, env),
};
}
function mapDeclineCode(code?: string | null): DunningContext["failureReason"] | undefined {
switch (code) {
case "expired_card": return "card_expired";
case "insufficient_funds": return "insufficient_funds";
case "authentication_required": return "authentication_required";
case "do_not_honor": return "do_not_honor";
default: return undefined;
}
}なぜ自社語彙にマップするのか。Stripe の decline_code は将来増減するので、エージェントのプロンプトを直接 decline_code 文字列に依存させると壊れやすいからです。ドメイン側の語彙を 5 種類くらいに絞り、新しいコードが来たら unknown に倒してアラートを出す運用にしておくと、長期的に保守が楽になります。
このあたりの設計判断は、SaaS の Webhooks をたくさん書いている人ほど刺さるはずです。詳しくは Antigravity ✕ Stripe フルスタック SaaS デプロイガイド で書いた、メータリングと請求書発行を含むフローも合わせて読むと全体像が掴みやすいかもしれません。
ステップ 3: Dunning Agent を Antigravity 上で定義する
ここから AI エージェントの出番です。Antigravity の agents/ ディレクトリに dunning-orchestrator.md を置き、次のようにツールと判断方針を明示しておきます。
# Dunning Orchestrator Agent
## Role
Stripe の課金失敗イベントを受け取り、顧客とビジネスの双方にとって最も健全なリカバリアクションを 1 つだけ選んで実行する。
## Available tools
- send_email(template_id, customer_email, variables): Resend 経由でメール送信
- post_slack(channel, blocks): Slack に通知
- update_customer_state(customer_id, state, note): 内部 KV を更新
- request_stripe_smart_retry(invoice_id, schedule): Stripe Smart Retries の再スケジュール
- offer_winback_discount(customer_id, percent_off, valid_days): クーポン発行 + メール送信
- escalate_to_human(reason): 自動対応を諦め、サポートにエスカレーション
## Decision policy
1. failureReason = "card_expired" → 顧客にカード更新リンクを送る (template "card_update_request")
2. failureReason = "insufficient_funds" かつ attemptCount <= 2 → Stripe Smart Retries に任せる + 顧客には控えめな通知 (template "soft_payment_reminder")
3. attemptCount >= 3 かつ planTier in ["pro","team"] かつ totalLifetimeValueJpy > 30000 → escalate_to_human
4. customerSegment = "loyal" かつ failureReason != "card_expired" → offer_winback_discount(percent_off=20, valid_days=14)
5. 不明なケースは escalate_to_human + Slack 通知
## Hard constraints
- 同じ顧客に 24 時間以内に 2 通以上のメールを送らない
- send_email を呼ぶ前に customer_email が空でないことを必ず確認する
- offer_winback_discount は同一顧客に対して 90 日に 1 回までポイントは「ハードな制約」を曖昧にしないことです。AI エージェントは柔軟ですが、安全弁になるルールをコード側で強制しないと、運用が始まった瞬間に怪しい振る舞いが出ます。私の経験では、同じ顧客に 24h 以内に 2 通以上のメールを送らない というたった 1 行があるかないかで、初回ローンチの安心感がまったく変わりました。
ステップ 4: エージェント呼び出しと結果ハンドリング
Webhook ハンドラから直接エージェントを呼ぶのではなく、Cloudflare Queue(または D1 + Cron)でジョブとして扱います。エージェント呼び出しは数百 ms 〜 数秒かかることがあるためです。
// lib/dunning/run-agent.ts
import { GoogleGenAI, FunctionDeclaration } from "@google/genai";
const tools: FunctionDeclaration[] = [
{
name: "send_email",
description: "送信テンプレートと変数を指定してメール送信",
parameters: {
type: "object",
properties: {
template_id: { type: "string", enum: ["card_update_request", "soft_payment_reminder", "winback_offer", "final_warning"] },
customer_email: { type: "string" },
variables: { type: "object" },
},
required: ["template_id", "customer_email"],
},
},
// ... 他のツール宣言を同様に
];
export async function runDunningAgent(ctx: DunningContext, env: Env) {
// 必須: ハード制約のチェック (エージェントの判断より優先)
if (!ctx.customerEmail) {
await postSlack(env, `[dunning] customer ${ctx.customerId} has no email — skipping`);
return { action: "skipped", reason: "no_email" };
}
const recent = await env.BILLING_KV.get(`mail-throttle:${ctx.customerId}`);
if (recent) {
return { action: "skipped", reason: "throttled" };
}
const ai = new GoogleGenAI({ apiKey: env.GEMINI_API_KEY });
const response = await ai.models.generateContent({
model: "gemini-3-pro",
contents: [
{ role: "user", parts: [{ text: buildPrompt(ctx) }] },
],
config: {
tools: [{ functionDeclarations: tools }],
systemInstruction: await loadAgentMd(env, "dunning-orchestrator.md"),
temperature: 0.2, // 判断の再現性を上げる
},
});
// function_call を 1 つだけ実行する設計にしている
const call = response.functionCalls?.[0];
if (!call) {
await postSlack(env, `[dunning] agent returned no action for ${ctx.customerId}`);
return { action: "no_action" };
}
const result = await dispatchTool(call.name, call.args, ctx, env);
await env.BILLING_KV.put(
`mail-throttle:${ctx.customerId}`,
"1",
{ expirationTtl: 60 * 60 * 24 } // 24h スロットル
);
return { action: call.name, ...result };
}ここで意識しているのは 「エージェントの判断は 1 回 1 アクション」 に絞っていることです。複数の function_call を許すと、たとえば「メール送信 + Slack 通知 + クーポン発行」を全部一気にやろうとして、片方が失敗したときの整合性が一気に難しくなります。1 アクションに絞り、必要ならエージェントを再度呼び出す。これでロールバック設計がシンプルになります。
期待する動作: 課金失敗から 30 秒以内にエージェントが 1 アクションを選び、Resend でメールが届き、KV に 24h スロットルが書き込まれます。
ステップ 5: メール文面を AI に出し分けさせる
テンプレートは固定文ではなく、Resend + React Email の中で AI に動的に埋めさせています。react-email で骨格を書き、ヒーローコピーだけを Gemini に生成させる構造です。
// emails/CardUpdateRequest.tsx
import { Body, Container, Heading, Text, Button, Html } from "@react-email/components";
export default function CardUpdateRequest({
firstName,
amountFormatted,
updateLink,
empathyParagraph, // Gemini が文脈に応じて生成
}: { firstName: string; amountFormatted: string; updateLink: string; empathyParagraph: string }) {
return (
<Html>
<Body>
<Container>
<Heading as="h2">{firstName} さん、お支払い情報のご確認をお願いできますか</Heading>
<Text>{empathyParagraph}</Text>
<Text>今回お試しした金額は {amountFormatted} です。下のボタンから新しいカードに更新いただけます。</Text>
<Button href={updateLink}>カード情報を更新する</Button>
<Text>もしすでに対応済みでしたら、本メールは無視していただいて構いません。</Text>
</Container>
</Body>
</Html>
);
}empathyParagraph のように、文脈に応じて優しさの量を調整したい部分だけを AI に任せるのがコツです。すべてを AI に書かせると一貫性が崩れますし、逆に全部固定文だと「自動送信されたメールだな」と一発でバレて読まれません。
メール送信周りのリトライ・順序保証については、私のメインのメール戦術を Antigravity と Resend で作る React Email 自動化パイプライン でまとめています。Dunning と通常の通知メールでテンプレートを共有できる設計にしておくと、運用負荷がかなり下がります。
ステップ 6: Slack エスカレーションの設計
ハードな判断を AI に丸投げしないために、エスカレーション用の Slack 通知が要になります。次のような Block Kit メッセージを使っています。
// lib/dunning/slack.ts
export function buildEscalationBlocks(ctx: DunningContext, reason: string) {
return [
{
type: "header",
text: { type: "plain_text", text: `:rotating_light: Dunning escalation` },
},
{
type: "section",
fields: [
{ type: "mrkdwn", text: `*顧客*\n<https://dashboard.stripe.com/customers/${ctx.customerId}|${ctx.customerEmail}>` },
{ type: "mrkdwn", text: `*プラン*\n${ctx.planTier} (${ctx.isAnnual ? "年額" : "月額"})` },
{ type: "mrkdwn", text: `*金額*\n${ctx.amountDue / 100} ${ctx.currency.toUpperCase()}` },
{ type: "mrkdwn", text: `*失敗理由*\n${ctx.failureReason}` },
{ type: "mrkdwn", text: `*試行回数*\n${ctx.attemptCount}` },
{ type: "mrkdwn", text: `*LTV (JPY)*\n¥${ctx.totalLifetimeValueJpy.toLocaleString()}` },
],
},
{
type: "section",
text: { type: "mrkdwn", text: `*Why escalated*\n${reason}` },
},
{
type: "actions",
elements: [
{ type: "button", text: { type: "plain_text", text: "Send winback offer" }, action_id: "dunning_winback" },
{ type: "button", text: { type: "plain_text", text: "Mark as lost" }, action_id: "dunning_mark_lost", style: "danger" },
],
},
];
}エスカレーションした瞬間に、一目で意思決定できるだけの情報を Slack に並べるのが大事です。私はここで何度も失敗しました。Slack に「失敗が起きた」だけ通知しても、結局 Stripe Dashboard を開きにいくので意味がありません。LTV・プラン・失敗理由・試行回数までセットで見えれば、3 秒で「これは追いかける/諦める」を判断できます。
よくある間違い・落とし穴
ここからは、私が実際にハマって書き直した部分です。
1. Stripe Smart Retries とアプリ側リトライを二重化してしまう
Stripe にはデフォルトで Smart Retries(最大 4 回まで自動再試行)があります。これを把握していないと、アプリ側でも独自の cron リトライを書いてしまい、顧客に 同じ請求が 8 回トライされる事故が起きます。Stripe Dashboard の Settings → Billing → Subscriptions → Retries を必ず先に確認し、アプリ側のリトライは「Stripe が諦めた後の最終手段」だけに限定してください。
2. invoice.payment_failed だけを見ている
実は失敗イベントは複数あり、課題に応じて使い分けが必要です。
invoice.payment_failed: 自動回収が失敗invoice.payment_action_required: 3DS 認証が必要(顧客の操作待ち)customer.subscription.updated(status: past_due → unpaid): リトライ全敗customer.subscription.deleted: 完全に失効
Dunning パイプラインは少なくとも invoice.payment_failed と invoice.payment_action_required を別文面で扱うべきです。3DS 待ちの顧客に「カードを更新してください」と送ると逆効果になります。
3. テスト環境で本番のメールを送ってしまう
STRIPE_SECRET_KEY の prefix で sk_test_ か sk_live_ を判定し、test の場合は Resend を sandbox モードに切り替えるか、社内宛アドレスにリダイレクトする実装が安全です。if (process.env.STRIPE_SECRET_KEY?.startsWith("sk_test_")) { /* sandbox */ } を必ずどこかに入れてください。
4. 「reason: unknown」の沼
decline_code が増えていくと、unknown の比率が静かに上がります。週次で SELECT failure_reason, count(*) FROM dunning_events GROUP BY 1 を回し、unknown の絶対数と割合を観測してください。10% を超えたら新しいマッピングを追加するサインです。
5. クーポンを連発してしまう
「とりあえず winback クーポン」を全失敗顧客に投げると、解約意思のない人にまでディスカウントが配られて単価が崩れます。offer_winback_discount の発火条件には必ず 顧客セグメント・LTV・最終支払い日 の 3 つを噛ませることをおすすめします。私は customerSegment === "loyal" && totalLifetimeValueJpy > 30000 を入れて、月次の発行件数を 5 分の 1 まで絞れました。
本番運用で必須にしている観測項目
以下の 5 つは Looker Studio に常時表示しています。
- 課金失敗イベント数(理由別・日次)
- リカバリ率(
payment_failed→payment_succeededまで 14 日以内に到達した割合) - メール送信からのカード更新クリック率
- Slack エスカレーション件数とその後 24h での解決率
unknownfailureReason の割合
このうちリカバリ率が一番大事です。AI エージェント導入前後で、これがどう動くかを必ず比較してください。私のケースでは、初月で 38% → 61% に上がりました。新規 MAU を増やすより、既存顧客の解約を減らすほうが、計算してみると同等のインパクトがあったのです。
応用シナリオ
このパイプラインは Dunning 以外にも応用できます。
- トライアル終了直前のリマインド:
customer.subscription.trial_will_endを起点に、利用ログから「使い込んでいる人」「触っていない人」を AI で判別し、それぞれに別文面を送る - アップグレード提案: 利用量が現プランを超えそうな顧客に、上位プランの案内を AI が文面化
- 解約意思表明への対応:
customer.subscription.deletedイベントを起点に、過去の利用パターンを踏まえた winback メールを翌日送る
これらを「Dunning Agent」のサブエージェントとして並べると、SaaS の収益サイクル全体を 1 つのエージェント体系で運用する設計が見えてきます。私は今このパスを歩んでいる最中で、dunning-orchestrator.md を書き写してそのまま lifecycle-orchestrator.md を作り、サブエージェントを増やしている最中です。
メータリング側との接続については、Antigravity AI Agent と Stripe Meter Events で作る使用量課金 や、トータルの収益最適化視点については Antigravity サブスクリプション収益最適化 アドバンス編 も参考になるはずです。
SaaS の課金まわりはどうしても泥臭く、本だけで体系的に学ぶのが難しい領域です。実装の細部に入る前に、ビジネス側の言葉で全体を捉え直すと、エージェントに渡すドメインルールが書きやすくなると思います。
今日からの一歩
長くなったので、最後に「ここから何をすればいいか」を 1 つだけに絞ります。
Stripe Dashboard の Reports → Revenue → Failed payments を開き、直近 30 日の失敗件数と原因の上位 3 つを確認します。
これだけで、自分の SaaS にとって Dunning が自動化に値するボリュームかどうかが見えてきます。月に数件しかなければ、まず手作業で十分です。月に数十件を超えてきたら、この記事のステップ 1 から書き始めるとちょうどいい投資効率になります。私自身も、最初に手を動かしたのはまさにこの数字を見た翌日でした。