コンテンツへスキップ

Stripe webhook で多言語メールを出す — 通貨ベースの言語推定パターン

SaaS を国際展開していく過程で、地味に詰まりがちなのが Stripe webhook が送るシステムメールです。購入完了・更新成功・支払い失敗・プラン変更 — Stripe からのイベント通知をきっかけに送る 4 種類のメールが、知らないうちに 全部日本語固定 になっていた、という事故をこの間踏みました。

英語で課金している海外ユーザーが、購入確認も失敗通知もすべて日本語で受け取り続けていた。確認しないと永遠に存在し続けるタイプの漏れです。今回はこの修正で採用した「通貨ベースの言語推定」設計と、mb_language まわりの小さな罠を整理しておきます。

「ユーザーの言語」をどこから取るか — 3 つの選択肢

webhook 経由で送るメールの言語をどう決めるか、設計の選択肢は大きく 3 つありました。

案 A. DB に language カラムを持つ: ユーザー登録時に言語を保存しておいて、メール送信時に DB から引く。最も「正攻法」だが、DB マイグレーションが必要・既存ユーザー全員に「言語不明」状態の期間ができる・初期登録時に言語シグナルがない場合は推測ロジックがどちらにせよ必要、という難点がある

案 B. Stripe API でユーザー情報から引く: stripe.Customer.retrieve()preferred_locales を取る。情報源としては正しいが、Stripe API への追加 round trip が必要・全顧客が preferred_locales を設定しているとは限らない

案 C. Stripe イベントの currency から推定: webhook の payload に必ず含まれる currencyusd / jpy 等)から言語を推定する。追加 API コール不要・DB マイグレーション不要・既存ユーザーにも当日適用

採用したのは 案 C でした。理由は次の通りです:

  • 通貨は 購入時点で確定する強いシグナル で、後から変わらない
  • Webhook payload に元々含まれているデータなので、追加コストゼロ
  • DB を一切触らずに済むので、デプロイ時のリグレッションリスクが低い
  • 既存ユーザーにも自動適用される(移行スクリプトが不要)

lang_from_currency の実装

採用した関数はこれだけです:

/** Stripe イベントの currency から表示言語を推定する */
function lang_from_currency(string $currency): string {
    $en_currencies = ['usd'];   // USD で課金 → 英語ユーザー
    return in_array(strtolower($currency), $en_currencies, true) ? 'en' : 'ja';
}

USD なら英語、それ以外(JPY を含む)なら日本語、というシンプルな割り切り。EUR や GBP も将来 $en_currencies 配列に足せば、その通貨で課金しているユーザーは英語メールに切り替わります。

「割り切り」と書きましたが、実運用では十分な精度です。JPY で課金している英語ユーザーは構造的にいないし、USD で課金している日本語話者は誤推定で英語メールを受け取りますが、これは BCP 47 の Accept-Language で取れる情報よりも購入意思に基づく明示的なシグナルになります。

4 つの webhook イベントで currency を取り出す

webhook で受ける Stripe イベントは 4 種類あり、それぞれ payload の構造が違うので currency の取り出し方も少し違います。

// 1. checkout.session.completed — 購入完了メール
$checkout_lang = lang_from_currency($session['currency'] ?? 'jpy');
send_license_email($email, $client_name, $key, $plan, $period, $checkout_lang);

// 2. invoice.payment_succeeded — 更新成功メール
$renewal_lang = lang_from_currency($invoice['currency'] ?? 'jpy');
send_renewal_email($email, $client_name, $plan, $renewal_lang);

// 3. invoice.payment_failed — 支払い失敗メール
$failed_lang = lang_from_currency($invoice['currency'] ?? 'jpy');
send_payment_failed_email($email, $client_name, $plan, $failed_lang);

// 4. customer.subscription.updated — プラン変更メール
//    subscription から通貨を辿る経路
$changed_lang = lang_from_currency($sub_currency);
send_plan_changed_email($email, $client_name, $old_plan, $new_plan, $changed_lang);

?? 'jpy' のフォールバックは、万が一 currency フィールドが欠落したイベント(過去の検証イベントで時々ある)でも例外を投げずに ja に倒すための保険です。

件名エンコードの罠 — mb_language('uni' or 'Japanese')

ここが一番ハマりました。PHP の mb_send_mail() で件名を送る時、mb_language() の設定が件名の MIME エンコード方式 を決定します。

mb_language($lang === 'en' ? 'uni' : 'Japanese');
  • mb_language('Japanese'): 件名は ISO-2022-JP で MIME エンコードされる(日本特有・JIS X 0208 ベース)
  • mb_language('uni'): 件名は UTF-8 Base64 でエンコードされる(国際メール仕様 RFC 2047 に厳密準拠)

何が問題か。mb_language('Japanese') のまま英語件名 "Your license key for WP Maintenance Manager" を送ると、Gmail や Outlook の スパムスコアが上がる経路に入ります。日本語メーラー以外には ISO-2022-JP エンコードの英語件名は「奇妙な MIME ヘッダ」として認識されることがあるためです。

修正は単純で、メール関数 4 つすべてで $lang に応じて mb_language() を切り替えるだけ。

function send_license_email($email, $client_name, $key, $plan, $period, $lang = 'ja') {
    mb_language($lang === 'en' ? 'uni' : 'Japanese');
    // ... 以降の subject / body は $lang で日英分岐
}

mb_language('uni') は PHP 7.2+ で利用可能(RFC 2047 準拠の Base64 UTF-8 エンコード)。Stripe Webhook を扱うバックエンドで多言語化するなら、ほぼ必須の知識だと思います。

DB マイグレーションが要らない設計の良さ

結果として、このラウンドではデータベースを一切触っていません。すべての変更は webhook.php の 1 ファイル内で完結し、追加された PHP 関数は lang_from_currency 1 つだけ。新しいユーザーへの適用はもちろん、過去に購入した英語ユーザーへの次回更新メールも、即座に英語化されました。

これは「ステートレスなイベント駆動アーキテクチャ」と「強いシグナル(通貨)の活用」の組合せが効いた例です。DB に状態を持つ設計だと、過去ユーザーの language を埋めるバックフィル作業が発生して、漏れたユーザーは旧言語のままになる経路ができてしまいます。

まとめ — webhook 多言語化の 3 つの引き出し

このラウンドから取り出せる原則は次の 3 つでした。

  1. イベント payload に強いシグナルがあるなら DB を介さない方が速い — 通貨は購入時点で確定する明確なシグナル。Stripe イベントに常に含まれている情報を起点にすれば、追加 DB / API コール不要で済む。設計の単純さは保守コストを下げる
  2. mb_language('uni') で件名エンコードを UTF-8 Base64 に揃えるmb_language('Japanese') のまま英語件名を送ると ISO-2022-JP エンコードでスパム判定を引きやすい。日英両方を扱うなら言語に応じて切り替える
  3. 多言語化は「漏れがないか」を疑うのが第一歩 — 「メール 4 種類のうち実は全部日本語固定だった」のような事故は、最後にメール送信処理に手を入れた人が気づかないと発覚しない。Stripe イベントを扱う関数は 言語パラメータを明示的に受け取る API に揃えておくと、新しいイベントを追加する時にも気づきやすい

国際展開している SaaS で「英語ユーザーに日本語メールを送っていた」状態は、確認するまで気づきません。Stripe イベントから currency を取り出して言語を推定するパターンは、1 ファイル・1 関数で大半の漏れを塞げるコスパの良い設計の引き出しだと思います。