私が運営している小さな 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 での解決率
unknown failureReason の割合
このうちリカバリ率 が一番大事です。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 の課金まわりはどうしても泥臭く、本だけで体系的に学ぶのが難しい領域です。実装の細部に入る前に、ビジネス側の言葉で全体を捉え直すと、エージェントに渡すドメインルールが書きやすくなると思います。
失敗理由ごとに、文面とタイミングを変える
エージェントに判断を渡すといっても、人間側が「どの理由にはどう動くか」の基準を持っていないと、出力の良し悪しを評価できません。私が Decision policy の下敷きにしている対応表を、そのまま載せておきます。リトライ間隔は Stripe Smart Retries に任せる前提で、こちらが管理するのは「いつ・どの経路で・どんなトーンで人に触れるか」です。
失敗理由 主因 最初の接触 経路 トーン
card_expired(有効期限切れ) 顧客側の更新漏れ 即時 メール+アプリ内バナー 事務的・更新リンク主体
insufficient_funds(残高不足) 一時的な資金繰り 翌営業日まで待つ メールのみ 柔らかく・再請求日を明示
authentication_required(3DS) 追加認証待ち 即時 アプリ内+メール 操作誘導・「更新」と言わない
do_not_honor / network 拒否 発行銀行側の判断 2回失敗後 メール+有人 原因が顧客側にない前提で丁寧に
unknown(マッピング外) 未分類 Slack 即エスカレーション 有人 テンプレを送らず人が確認
この表で一番伝えたいのは、「残高不足」だけはすぐに連絡してはいけない ということです。給料日前にカードが通らなかった方に当日メールを送ると、まだ払う気のある相手を急かして心証を損ねます。私は最初これを毎回即時通知にしていて、かえって解約が増えました。翌営業日まで黙って待つように変えただけで、残高不足カテゴリのリカバリ率が一段上がっています。
テスト環境で Dunning を壊さずに検証する
Dunning パイプラインの怖いところは、バグると本番の顧客に誤ったメールが飛ぶ ことです。私は一度、正規化のテスト中に sk_test_ のつもりで本番キーを読み込んでいて、実在の顧客に「カードを更新してください」と送ってしまったことがあります。幸い 1 通で気づきましたが、肝が冷えました。
それ以来、検証は次の三層に固定しています。
Stripe CLI で失敗イベントを合成する 。stripe trigger invoice.payment_failed で本物に近い Webhook を流し込み、正規化ロジックだけを単体で確かめます。
送信経路を環境で遮断する 。STRIPE_SECRET_KEY が sk_test_ で始まる間は、メール送信関数の冒頭で必ず社内アドレスへリダイレクトします。判定を「送る直前」に置くのがコツで、上流のどこかで漏れても最後の砦になります。
エージェント出力をスナップショットで固定する 。失敗理由ごとの代表ケースを 10 件ほど用意し、生成された文面を承認済みスナップショットと比較します。プロンプトを変えたとき、意図しない別カテゴリの文面まで揺れていないかを機械的に拾えます。
特に 3 番目は、AI に文面を任せたからこそ必要になった工程です。決め打ちの文面なら差分を読めば済んだものが、生成だと「全カテゴリでトーンが微妙にずれていた」という事故が起こり得ます。
3 か月運用して、数字がどう動いたか
導入前後を理由カテゴリ別に並べると、効いた場所がはっきりします。本文の前半で「初月 38% → 61%」と書きましたが、3 か月ならすと内訳はこうでした。
失敗理由 件数の割合 導入前リカバリ率 3か月後 効いた打ち手
有効期限切れ 約41% 52% 74% アプリ内バナー+更新リンクの即時化
残高不足 約27% 34% 58% 翌営業日まで待つ/再請求日の明示
3DS 認証 約18% 29% 63% 「更新」ではなく「認証」と書き分け
ネットワーク拒否 約9% 22% 31% 2回失敗後の有人フォロー
unknown 約5% — — Slack 即エスカレーション
一番伸びたのは「残高不足」でした。打ち手はコードでも AI でもなく、送るのを我慢する という判断です。逆に「ネットワーク拒否」はどう頑張っても回収しにくく、ここに人手をかけすぎないと割り切れたことのほうが、運用負荷の面では大きかったかもしれません。
損益分岐の目安も書いておきます。個人開発で複数のサービスを回している私の規模では、この仕組みの構築と保守にかけた時間を時給換算で割り戻すと、月の課金失敗が 30 件を超えたあたり で元が取れる計算でした。それ以下なら、手作業のほうが正直に安いです。自動化は救済策ではなく、ボリュームが出てから効く投資だと考えています。
今日からの一歩
長くなったので、最後に「ここから何をすればいいか」を 1 つだけに絞ります。
Stripe Dashboard の Reports → Revenue → Failed payments を開き、直近 30 日の失敗件数と原因の上位 3 つを確認します。
これだけで、自分の SaaS にとって Dunning が自動化に値するボリュームかどうかが見えてきます。月に数件しかなければ、まず手作業で十分です。月に数十件を超えてきたら、この記事のステップ 1 から書き始めるとちょうどいい投資効率になります。私自身も、最初に手を動かしたのはまさにこの数字を見た翌日でした。