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.
Where do you get “the user’s language” from? — three options
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:
- Currency is a strong, fixed-at-purchase signal — it doesn’t change later
- It’s already in the payload, so the cost is zero
- Zero DB touches means low regression risk on deploy
- Existing users get the new behavior automatically (no migration script)
Implementing 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.
Pulling 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.
The subject-encoding trap — 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 win of a no-DB-migration design
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.
Closing — three patterns for webhook i18n
Three principles worth keeping from this round:
- If the event payload carries a strong signal, skip the DB. Currency is a fixed-at-purchase signal that’s already in every Stripe event. Routing logic through that signal beats a DB-state design when the data is already available — fewer moving parts, less maintenance overhead
- Set
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 language - For multilingual coverage, suspect “is anything still hardcoded?” first. “Four mail functions were all hardcoded to Japanese” is the kind of bug that only surfaces when someone goes looking. Make every Stripe-event-handling function take an explicit language parameter in its signature, so adding a new event forces the author to think about language
If 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.