2014年に最初のアプリを公開してから、しばらくは「サポートメールを開けるのが怖い」期間が長く続きました。当時の私は、お問い合わせフォームを mailto: リンクだけで済ませていて、届くメールには端末情報も再現手順もなく、ほとんどが「動かない」「広告が多い」「金返せ」の三種類でした。たった一人で複数アプリを運用していたので、対応に追われると本業の制作時間が消えていきます。
その後、累計5,000万DLを超えるなかで何度も設計を見直し、いまは月数百件の問い合わせを1日30分以内で捌けるところまで来ました。ここでは、その「最小カスタマーサポート設計」を、私が今この瞬間にも運用している構成のままで書き残します。Rorkで作る個人開発アプリ、または既存の Expo / SwiftUI アプリの運用にも応用できます。
なぜ個人開発者ほどサポート設計が必要なのか
法人開発であればCSチームに任せられる仕事も、個人開発では全部自分に降ってきます。一日でも対応が遅れると、ストアレビューで★1が連鎖し、AdMob の eCPM とランキングが同時に落ちます。私が2018年に経験した事故では、AdMob 連携の起動時クラッシュを4日放置した結果、レビューが★4.4から★3.1に下がり、ピーク時月収の36%が消えました。サポートは「やればやるほどいい」ものではなく「最低限の品質を切らさないと事業が止まる」インフラです。
逆に言えば、ここを最小コストで運用できる仕組みを一度作ってしまえば、複数アプリ運用のスケーラビリティが一気に上がります。私の場合、現在運用中の壁紙アプリ・癒し系アプリ・引き寄せ系アプリの3カテゴリにまたがる十数本を、同じ問い合わせインフラに集約しています。
全体構成 — 3層の問い合わせフロー
私が運用している構成は以下の3層です。
アプリ内 FAQ (自己解決させる層)
アプリ内フォーム (情報付きで届かせる層)
メール対応 (人間が判断する最終層)
層が深くなるほどコストが上がるので、上の層で解決させるほど運用が軽くなります。私の実測では、FAQ 拡充だけで問い合わせ全体が約 70% 減りました(2023年8月のFAQ刷新前後比較)。
ステップ1: アプリ内 FAQ をオンライン化して回答ストックを溜める
まず一番上の層から作ります。アプリ内に FAQ を埋め込むのではなく、Web 上にホストして WebView で表示する方式を推奨します。アプリ更新なしで内容を差し替えられるためです。
// src/screens/SupportScreen.tsx
import { WebView } from "react-native-webview" ;
import { Platform } from "react-native" ;
const FAQ_URL = "https://your-domain.example/app-faq?app=wallpaper" ;
export function SupportScreen () {
const url = `${ FAQ_URL }&os=${ Platform . OS }&lang=${ getCurrentLocale () }` ;
return (
< WebView
source = { { uri: url } }
// iOSのSafe Area・スクロール慣性を維持
contentInsetAdjustmentBehavior = "automatic"
decelerationRate = "normal"
/>
);
}
ポイントは、URL に app / os / lang を付与しておくことです。サーバー側で「壁紙アプリ・iOS・日本語」の3条件に絞った FAQ を返せるようにしておくと、不要な情報でユーザーを迷わせずに済みます。
FAQ の中身は「実際に届いた問い合わせ」から逆算して書きます。届いたメールから繰り返し3回以上見たキーワードを抽出し、その日のうちに FAQ に1問追加するルールを私は2022年から続けています。FAQの問い合わせ削減効果は、増やし始めから1ヶ月程度のラグで効いてきます。
ステップ2: アプリ内フォームに「自動添付」を仕込む
FAQ で解決しなかったユーザーが進む層です。ここで mailto: を開くだけにすると、ほぼ100%の確率で「動かない」とだけ書かれたメールが届きます。一次切り分けに時間が溶けるので、フォームのほうで情報を自動収集します。
// src/screens/InquiryFormScreen.tsx
import * as Application from "expo-application" ;
import * as Device from "expo-device" ;
import * as Localization from "expo-localization" ;
import { Platform } from "react-native" ;
import { sendInquiry } from "../api/inquiry" ;
import { getRecentLogs } from "../debug/logBuffer" ;
import { getPurchaseHistory } from "../iap/purchaseStore" ;
type InquiryPayload = {
body : string ;
category : "bug" | "billing" | "feature" | "other" ;
diagnostic : {
appVersion : string | null ;
buildNumber : string | null ;
osName : string | null ;
osVersion : string | null ;
deviceModel : string | null ;
locale : string ;
timezone : string ;
recentLogs : string []; // 直近100行のログ
purchases : string []; // 復元可能な購入ID(個人情報なし)
crashLastSeen : string | null ;
};
};
export async function buildInquiryPayload ( body : string , category : InquiryPayload [ "category" ]) : Promise < InquiryPayload > {
return {
body,
category,
diagnostic: {
appVersion: Application.nativeApplicationVersion,
buildNumber: Application.nativeBuildVersion,
osName: Platform. OS ,
osVersion: Device.osVersion,
deviceModel: Device.modelName,
locale: Localization.locale,
timezone: Localization.timezone,
recentLogs: await getRecentLogs ( 100 ),
purchases: await getPurchaseHistory (),
crashLastSeen: await readLastCrashTimestamp (),
},
};
}
ここで意識しているのは「ユーザーに入力させない」ことです。OSバージョンや端末モデルを問い合わせフォームで打ってもらうと、半分以上の人が途中で離脱します。アプリ側で自動取得して、フォームの「送信内容を確認」画面で省略表示するのが体験的にちょうど良いです。
ログ収集はリングバッファで
getRecentLogs(100) は、メモリ上に直近100行のログを溜めておくリングバッファから取り出します。本番では console.log を直接出すのではなく、ラッパー越しに保存する設計にしています。
// src/debug/logBuffer.ts
const BUFFER_SIZE = 300 ;
const buffer : string [] = [];
export function logEvent ( tag : string , message : string , level : "info" | "warn" | "error" = "info" ) {
const ts = new Date (). toISOString ();
const line = `[${ ts }] ${ level . toUpperCase () } ${ tag }: ${ message }` ;
buffer. push (line);
if (buffer. length > BUFFER_SIZE ) buffer. shift ();
if (__DEV__) console. log (line);
}
export async function getRecentLogs ( count = 100 ) : Promise < string []> {
return buffer. slice ( - count);
}
クラッシュ直前の状況を把握する目的では、これだけで十分です。Crashlytics や Sentry のスタックトレースと相補的に使います。
PII(個人情報)を除去するレダクション層
ログにメールアドレスや決済トークンが混ざらないよう、送信前にレダクションをかけます。私は2020年に「ユーザーが本文に書いたメールアドレスをログにも記録してしまい、送信時に二重に含まれた」という小さな事故を経験して以来、必ずこのステップを挟んでいます。
// src/debug/redact.ts
const EMAIL = / [a-zA-Z0-9._%+-] + @ [a-zA-Z0-9.-] + \. [a-zA-Z] {2,} / g ;
const PHONE = / \+ ? \d {1,3} [- ] ? \d {2,4} [- ] ? \d {2,4} [- ] ? \d {2,4} / g ;
const TOKEN = /(eyJ [a-zA-Z0-9_-] + \. ) {2} [a-zA-Z0-9_-] + / g ; // JWT風
const IPADDR = / \b \d {1,3} (?: \. \d {1,3} ) {3}\b / g ;
export function redact ( input : string ) : string {
return input
. replace ( EMAIL , "[redacted-email]" )
. replace ( PHONE , "[redacted-phone]" )
. replace ( TOKEN , "[redacted-token]" )
. replace ( IPADDR , "[redacted-ip]" );
}
送信API側でこのレダクションを最終チェックとして必ず通すと、フロント側で漏れがあっても二重に守れます。
ステップ3: サーバー側は最小構成で十分
問い合わせを受け取るサーバーは、Cloudflare Workers + メール送信で組んでいます。データベースもキューも不要で、届いたものを SMTP でメールに変換するだけです。
// worker/inquiry.ts (Cloudflare Workers)
export default {
async fetch ( req : Request , env : Env ) {
if (req.method !== "POST" ) return new Response ( "Method not allowed" , { status: 405 });
const payload = await req. json <{ body : string ; category : string ; diagnostic : Record < string , unknown > }>();
const subject = `[${ env . APP_NAME }] ${ payload . category }: ${ truncate ( payload . body , 40 ) }` ;
const text = renderEmailBody (payload);
// Resend / Mailgun / SendGrid いずれでも可。ここは Resend の例。
const result = await fetch ( "https://api.resend.com/emails" , {
method: "POST" ,
headers: {
Authorization: `Bearer ${ env . RESEND_API_KEY }` ,
"Content-Type" : "application/json" ,
},
body: JSON . stringify ({
from: `${ env . APP_NAME } <no-reply@your-domain.example>` ,
to: env. SUPPORT_INBOX , // 自分のGmailアドレス
subject,
text,
reply_to: payload.diagnostic.contactEmail ?? undefined ,
}),
});
if ( ! result.ok) return new Response ( "Send failed" , { status: 502 });
return new Response ( "ok" );
} ,
} ;
function truncate ( s : string , n : number ) {
return s. length > n ? s. slice ( 0 , n) + "…" : s;
}
Cloudflare Workers の無料プランで月10万リクエストまで処理できるので、個人開発で問い合わせ用途なら数年は無料で運用できます。メール送信側は私の場合 Resend を使っていますが、月3,000通までは無料です。
ステップ4: Gmail 側で受け取って自動振り分けする
届いたメールは Gmail の「ラベル + フィルタ + 検索演算子」で振り分けます。GUI 操作だと再現性がないので、私は Apps Script でフィルタを管理しています。
// Google Apps Script
// 起動: スプレッドシート連動か、トリガーで毎週月曜実行
function ensureFilters () {
const rules = [
{ from: "no-reply@your-domain.example" , subject: "[wallpaper-app]" , label: "App/Wallpaper" },
{ from: "no-reply@your-domain.example" , subject: "[healing-app]" , label: "App/Healing" },
{ from: "no-reply@your-domain.example" , subject: "billing:" , label: "Priority/Billing" },
{ from: "no-reply@your-domain.example" , subject: "bug:" , label: "Priority/Bug" },
];
// Gmail API でフィルタを作成・更新(既存と比較して差分のみ追加)
// 詳細は Gmail API リファレンス参照
}
私の運用では、ラベル Priority/Billing と Priority/Bug が付いた問い合わせを最優先で開きます。Priority/Billing は AppleCare / Google サポートに転送される前に着地させたい層、Priority/Bug はクラッシュ・ストアレビュー連鎖の予兆を含む層です。それ以外は1日1回まとめて対応します。
ステップ5: 返信テンプレートと多言語化
返信は Gmail の「テンプレート機能(旧 Canned Responses)」で書き溜めています。よく使う返信は10種類程度で、最初に作っておくと、毎回の返信時間が平均で約4分から1分以下まで減りました。
返信テンプレート例(日本語版・公開できる粒度のみ):
インストールできない問題 : 「お問い合わせいただきありがとうございます。お送りいただいた情報を確認いたしました。〇〇という現象は、端末側のストレージ不足、または OS の権限設定が原因のことが多いです。下記の手順でお試しください…」
広告が多すぎるとのご意見 : 「貴重なご意見をありがとうございます。広告は無料でアプリをお使いいただくための運営費に充てさせていただいておりますが、頻度については継続的に見直しております。〇〇画面の広告については現在検証中で、〇月の更新で改善版を反映予定です」
特定機能が動かない : 「ご報告ありがとうございます。お送りいただいた診断情報から、〇〇という条件で発生する不具合と思われます。次回の更新(X.Y.Z)で修正予定ですので、しばらくお待ちください」
英語版テンプレートも同じ構造で用意します。私の場合、Gemini と Claude を併用して翻訳テンプレートを作り、ネイティブ表現として違和感のない言い回しに調整しています。テンプレートそのままで送らず、最初の1〜2文に「お問い合わせの件、確認しました」程度の個別文を必ず足すと、テンプレ感が消えて返信効率と満足度の両立ができます。
ステップ6: 自分宛のエスカレーション基準を決める
「全件丁寧に返す」だと個人開発では破綻します。私は以下の3カテゴリで対応レベルを分けています。
当日対応必須 : 決済関連・クラッシュ・データ消失・法的指摘
3日以内対応 : 機能不具合・操作方法の質問
1週間以内対応または無回答 : 機能要望・主観的な不満・スパム
ここで重要なのは「無回答を許容するカテゴリを明確に決める」ことです。すべて返すと決めると、サポート対応時間が際限なく増え、本業の制作時間が削れます。私は機能要望は基本的に「ありがたく拝読してロードマップに反映する」だけで、個別返信はしないと決めています。
ステップ7: 月次振り返りで FAQ にループバックする
月末に、対応した問い合わせのうち「同じ質問が3件以上来たもの」を FAQ に追加します。私は Google スプレッドシートに月別の集計テーブルを置き、各カテゴリの件数推移を見ています。
月 全件数 バグ 課金 機能要望 FAQ追加数
2025-11 412 38 14 89 6
2025-12 358 22 11 76 8
2026-01 287 17 9 61 5
このループを2年ほど回した結果、月の問い合わせ件数はピーク時の約 30% まで減りました。同時に解決率も上がり、ストアレビューの★4.5以上比率が安定するようになりました。
ステップ8: 一人運用でも壊れない3つの仕組み
最後に、長期運用で実際に効いた仕組みを3つだけ書きます。
「返信の下書きを書く時間」を1日のスケジュールに固定する : 私は毎朝6:30〜7:00 をサポート時間に充てています。それ以外の時間にメールを開くと、創作・制作モードが切れます。
「対応しないメール」を必ず読んでアーカイブする : 返信しないと決めたメールも、必ず一度は目を通します。傾向の変化(特定機能への不満が急増している等)はストアレビューより先にここに出ます。
クラッシュ・課金障害は別経路でも検知する : 問い合わせを待っていたら手遅れです。Crashlytics のクラッシュフリー率アラートと、Stripe / RevenueCat のエラー率アラートを Slack に送り、サポート系メールに頼らない検知系を必ず別に持ちます。
締めくくり
個人開発のカスタマーサポートは、「丁寧に返す」より「最初から問い合わせが少なくなる設計をする」ほうが、はるかにコストパフォーマンスが高いという実感があります。アプリ内 FAQ → 自動添付付きフォーム → 自動振り分け → テンプレート返信、というシンプルな流れに収めることで、私は十数本のアプリを一人で支え続けてこられました。
最初は「サポートメールが怖い」状態だった2014年の自分に、もし今の構成を渡せたら、たぶん3年は寿命が延びたと思います。同じように一人で複数アプリを運用している方の参考になれば幸いです。お読みいただきありがとうございました。