devto 2026-07-02 원문 보기 ↗
A subtle trap of taking a SaaS international: the system emails sent from your Stripe webhook. Purchase confirmation, renewal success, payment failure, plan change — four kinds of emails triggered by Stripe events. We recently discovered all four had been hardcoded to Japanese for months, sending Japanese receipts and failure notices to English-paying users overseas. The kind of bug that quietly persists forever unless you go looking.
This post walks through the currency-based language inference design we landed on, plus the small mb_language trap that nearly ruined the fix.
There were essentially three design options for picking the email language inside the webhook:
Option A. Keep a language column in the DB. The classical answer — store language at signup, look it up when sending mail. But it requires a DB migration, leaves existing users in "language unknown" until backfill, and you still need fallback inference for the first email anyway
Option B. Pull from the Stripe API. stripe.Customer.retrieve() exposes preferred_locales. Authoritative in theory, but adds a round trip per send, and not every customer has it set
Option C. Infer from the Stripe event's currency field. Always present in the webhook payload (usd / jpy / etc.). No extra API call, no DB migration, applies to existing users immediately
We picked Option C, because:
lang_from_currency
The whole helper is this one function:
/** Infer display language from a Stripe event's currency. */
function lang_from_currency(string $currency): string {
$en_currencies = ['usd']; // USD → English user
return in_array(strtolower($currency), $en_currencies, true) ? 'en' : 'ja';
}
USD means English; everything else (including JPY) means Japanese. EUR or GBP can be added to $en_currencies later when those markets open.
It's a "good enough" approximation, but it works in practice: there are essentially no English speakers buying in JPY, and the rare Japanese speaker on a USD plan ends up with English emails — which is more correlated to their explicit purchase intent than something like a browser Accept-Language header.
currency from the four webhook events
The four Stripe events have slightly different payload shapes, so currency lives in slightly different places:
// 1. checkout.session.completed — purchase confirmation
$checkout_lang = lang_from_currency($session['currency'] ?? 'jpy');
send_license_email($email, $client_name, $key, $plan, $period, $checkout_lang);
// 2. invoice.payment_succeeded — renewal success
$renewal_lang = lang_from_currency($invoice['currency'] ?? 'jpy');
send_renewal_email($email, $client_name, $plan, $renewal_lang);
// 3. invoice.payment_failed — payment failure
$failed_lang = lang_from_currency($invoice['currency'] ?? 'jpy');
send_payment_failed_email($email, $client_name, $plan, $failed_lang);
// 4. customer.subscription.updated — plan change
// (currency reached via the subscription object)
$changed_lang = lang_from_currency($sub_currency);
send_plan_changed_email($email, $client_name, $old_plan, $new_plan, $changed_lang);
The ?? 'jpy' fallback is insurance: if a test event somehow lacks currency, the function returns ja rather than throwing.
mb_language('uni' or 'Japanese')
This is where it gets sneaky. PHP's mb_send_mail() encodes the subject according to whatever mb_language() is set to:
mb_language($lang === 'en' ? 'uni' : 'Japanese');
mb_language('Japanese') encodes the subject in ISO-2022-JP via MIME (JIS X 0208-based, Japan-specific)mb_language('uni') encodes the subject in UTF-8 Base64 (strict RFC 2047 compliance)Why does it matter? If you leave mb_language('Japanese') in place and send an English subject like "Your license key for WP Maintenance Manager", the subject gets ISO-2022-JP MIME-encoded — and Gmail and Outlook's spam scoring goes up. Non-Japanese mail clients see a strange-looking encoded subject for what should be plain English text.
The fix is simple — flip mb_language() based on $lang in all four mail functions:
function send_license_email($email, $client_name, $key, $plan, $period, $lang = 'ja') {
mb_language($lang === 'en' ? 'uni' : 'Japanese');
// ... subject / body below branch on $lang
}
mb_language('uni') is available since PHP 7.2 (RFC 2047 compliant Base64 UTF-8). If you're handling Stripe webhooks and going multilingual, this is more or less mandatory knowledge.
The net of this round: the database was not touched. The entire change lives in one file, webhook.php, with one new helper function lang_from_currency. New users get the right language obviously, but existing users who bought months ago also get their next renewal email in the right language, automatically.
This is what a stateless event-driven architecture combined with a strong inference signal can buy you. A DB-state design would have required a backfill pass over historical users — and rows that got missed during backfill would have stayed wrong forever.
Three principles worth keeping from this round:
mb_language('uni') for non-Japanese subjects. ISO-2022-JP MIME encoding of English subject lines is a quiet contributor to spam-score creep. If you support both languages, switch encoding based on the user's languageIf your SaaS is international, "we've been sending Japanese emails to English-paying users" is the kind of failure that doesn't surface until you check. Inferring language from currency at the webhook layer is a one-file, one-helper pattern that closes most of those leaks at a remarkably low cost.