August 15, 2026 · 13 min read
App Store Connect Multilingual Replies: Catching Locale Mismatch Early
Replying to App Store reviews in 16 locales. Territory codes are ISO alpha-3 while locales are not, and a reply whose language differs from the review is rejected before the API call.
When I put a French draft in the same place, I got confidence: 0.733 and match: true. When I put the Korean body there, I got confidence: 0.929, match: false, and detected: ko.
In the second case, once the response was submitted, the customer had received a response they could not read. It had also already left a trace in App Store Connect. I had to make another call to delete it.
I normalized ASC territory from human-facing alpha-2 to request-facing alpha-3
Country codes come in both formats. Developers are familiar with ISO alpha-2 codes such as FR and KR, but ASC's filter[territory] expects ISO alpha-3 codes. Without normalizing them to three-letter codes such as FRA and KOR, the lookup criteria and locale-determination criteria could diverge.
locale.ts creates an ALPHA3_BY_ALPHA2 table for converting alpha-2 to alpha-3, along with an ALPHA2_BY_ALPHA3 table that reverses that mapping. territoryMap includes both the original territory and its alpha-3 form.
// care-mcp/src/locale.ts
ALPHA3_BY_ALPHA2 // FR → FRA
ALPHA2_BY_ALPHA3 // FRA → FR (앞의 표를 뒤집는다)
territoryMap // 원래 territory와 alpha-3를 둘 다 키로 넣는다FR and FRA both point to fr-FR. I accepted both formats while standardizing only the filter sent to ASC to alpha-3.
In the tests, I verified that a request with territory: 'KR' was converted to filter%5Bterritory%5D=KOR in the URL. The same function also accepts alpha-3 inputs such as KOR, TWN, and BRA.
| Input | ASC filter | Example locale |
|---|---|---|
FR |
FRA |
fr-FR |
KR |
KOR |
ko |
DE |
DEU |
de-DE |
BRA |
BRA |
pt-BR |
Unmapped IDN |
Used for the locale fallback | en-US, fallback: true |
Since one language covered multiple countries, I kept the final correction map separately
I supported sixteen locales: ko, ja, zh-Hans, zh-Hant, en-US, de-DE, fr-FR, es-ES, it, nl-NL, pt-BR, tr, vi, da, no, and sv. Those sixteen locales shared 44 territories.
The initial entries had a list of launch territories for each locale. English covered eight countries, while Spanish covered seven territories, including the US. German and French also shared multiple countries, such as Switzerland and Luxembourg.
| Locale | territories | Count |
|---|---|---|
en-US |
US, GB, AU, CA, IE, NZ, IN, ZA | 8 |
de-DE |
DE, AT, CH, LI, LU | 5 |
fr-FR |
FR, BE, MC, CH, LU | 5 |
es-ES |
ES, MX, AR, CL, CO, PE, US | 7 |
nl-NL |
NL, BE | 2 |
pt-BR |
BR, PT | 2 |
Switzerland, CH, appeared in both de-DE and fr-FR, and Belgium, BE, also appeared in both fr-FR and nl-NL. When I inserted the same key into one Map in sequence, the last value remained. I did not hide this collision; I exposed it through a final correction map.
The correction map was not a rule for determining the language of every country. It cleaned up duplicates created by the arrays through a final write. It contained eleven entries.
// care-mcp/src/locale.ts:36-37
{ US: 'en-US', GB: 'en-US', DE: 'de-DE', AT: 'de-DE', CH: 'de-DE',
FR: 'fr-FR', BE: 'fr-FR', ES: 'es-ES', MX: 'es-ES', AR: 'es-ES', PT: 'pt-BR' }
// 같은 표를 alpha-3 키로 한 번 더 넣는다: USA, GBR, DEU, AUT, CHE, FRA, BEL, ESP, MEX, ARG, PRTI inserted the same table twice: once with alpha-2 keys and once with alpha-3 keys. Both CH and CHE had to point to de-DE, and because the earlier iteration also inserted the alpha-3 keys, I had to apply the correction to both sets.
Of the eleven entries, only four actually resolved conflicts: CH, BE, PT, and US. The other seven simply rewrote values they already had. Since DE appeared only in the de-DE array, it was already de-DE without a correction. Rather than listing only the four that were needed, I wrote down all eleven related entries. The code alone does not distinguish which ones resolved conflicts from which ones served as checks.
Choosing German for Switzerland was not a linguistic judgment. I had to choose one, so I chose one and left that choice in the code. Swiss customers in French-speaking Switzerland receiving German responses were the cost of that choice.
I combined Unicode features and scores directly for language detection
localeCheck was not a model for evaluating translation quality. I used it as a quick check to see whether the body had the characteristics of the expected locale. Empty bodies and unsupported locales returned match: false and confidence: 0 from the start.
I accumulated the score from four types of signals.
| Signal | Score |
|---|---|
Hangul [가-힣] |
10 for ko |
Kana [ぁ-ゖァ-ヺ] |
10 for ja |
| CJK ideograph range | 2 each for zh-Hans and zh-Hant |
Simplified-only characters 这来个为么说与应请谢汉 |
5 for zh-Hans |
Traditional-only characters 這來個為麼說與應請謝漢 |
5 for zh-Hant |
| Language-specific characteristic words | 2 for that language |
| Diacritics | 3 for that language |
I assigned 10 points to Hangul and kana because they did not share characters with other languages. Since CJK ideographs were shared, I assigned a lower range score and checked separately for characters that distinguished simplified from traditional Chinese.
Diacritics differed by language, such as de-DE's äöüß, fr-FR's àâçéè…, and tr's çğıİöşü. They were signals that could be detected even in shorter bodies than those containing characteristic words.
// care-mcp/src/locale.ts:101-112 원문
const sorted = [...scores.entries()].sort((a, b) => b[1] - a[1]);
const [detected, top] = sorted[0]; const second = sorted[1]?.[1] ?? 0;
const confidence = top <= 0 ? 0 : Math.min(1, Math.max(0, (top - second + 1) / (top + 2)));
const match = detected === expect && top > 0 && (top - second >= 1 || ['zh-Hans', 'zh-Hant'].includes(expect));
return { match, detected: top > 0 ? detected : null, confidence: Number(confidence.toFixed(3)), reason: match ? `${detected} 특징이 확인되었습니다.` : `감지된 언어(${detected ?? '없음'})가 요청 로케일과 다릅니다.` };
}The confidence formula was (top - second + 1) / (top + 2). It increased when the score difference between first and second place was large and decreased when they were close.
Simplified and traditional Chinese first received the same score from the shared ideograph range. As a result, top and second were likely to be close. I exempted the score-difference condition of at least 1 only when one of those two locales was expected.
This detector failed by rejecting valid input
I moved the same function as-is and tried it with a few sentences.
| Body | Expected locale | top·second | confidence | match |
|---|---|---|---|---|
| Hello. I will help you. | fr-FR |
12·0 | 0.929 | false |
| Merci pour votre message. | fr-FR |
4·0 | 0.833 | true |
| Hallo, dank voor het bericht. | nl-NL |
6·0 | 0.875 | true |
| Tak for din besked. Vi ser på det. | da |
9·7 | 0.273 | true |
| Vi ser på det og sender en opdatering. | da |
11·11 | 0.077 | false |
| Thanks. | en-US |
0·0 | 0 | false |
When I expected fr-FR for a Korean body, it was calculated as 12 points versus 0 and was rejected.
The following three lines showed the limitation of this approach.
Danish and Norwegian shared six of the seven words in the word list. og, det, en, med, for, and ikke overlapped, while only tak and takk differed. Both also used [æøå] for diacritics. As a result, a Danish sentence containing tak was separated only narrowly at 9 points versus 7, and without that word the scores tied at 11 points versus 11.
When the scores tied, top - second >= 1 failed, so match was false. A correct Danish response was rejected. The last line was the same type of case. Thanks. did not match the word boundary for thank in the list, so it received 0 points and the English response was not recognized as English.
The failure was one-sided. Rather than allowing the wrong language through, this check blocked the correct language. When it blocked a response, a person had to try again; when it passed one, it remained on the customer's screen. Since the cost of reversing those outcomes differed, I considered this direction of failure preferable.
I could not tell how often Danish and Norwegian responses were actually blocked because there had been zero reviews. What I knew at that point was only that those two languages had a margin of 2 points.
I rejected mismatches before fetching the review when an explicit locale was specified
I found that the ASC call order, rather than the inspection function, created the language boundary. If the input contained locale: 'fr-FR' but the body was in Korean, I did not even issue the review GET request.
// care-mcp/src/asc.ts — reply()의 앞부분
if (![...body].length || [...body].length > 5970) return err('BAD_INPUT', ...);
const explicitLocale = stringValue(input.locale);
if (explicitLocale !== undefined) {
const preCheck = localeCheck(body, explicitLocale); // 리뷰를 조회하기 전에
if (!preCheck.match) return err('LOCALE_MISMATCH', ...); // 여기서 끝난다
}
const dryRun = input.dry_run === true || !this.context.config.writeEnabled;
if (dryRun) return ok({ dry_run: true, ... }); // 쓰기가 닫혀 있으면 여기까지
const approval = await verifyApproval(...); // 승인 확인
const review = await this.getReview(reviewId); // 이제야 GET
const locale = explicitLocale ?? localeForTerritory(review.territory).locale;
if (!localeCheck(body, locale).match) return err('LOCALE_MISMATCH', ...); // 한 번 더I treated the order as the substance of this function: input validation, explicit locale validation, dry run, approval, fetch, revalidation, and only then POST or PATCH.
When no explicit locale was provided, I fetched the review first to obtain the territory. In that case, I determined the locale after the GET and checked it again immediately before writing to ASC.
After sending the reply, I recorded it in care.review_reply. Because this used ON CONFLICT(review_id) DO UPDATE, replying to the same review again did not add another row; it updated the existing one. Even if recording failed, the reply remained valid, so I included logged: false in the response and continued.
The reply body limit was 5970 characters. This was the upper limit accepted by ASC.
| Input | Execution order | Result |
|---|---|---|
locale=fr-FR, Korean body |
localeCheck → no GET | LOCALE_MISMATCH |
No locale, territory FRA |
GET → determine fr-FR → recheck |
Rejected before the ASC call if mismatched |
| Locale and body matched | Verify approval → ASC request | Recorded in care.review_reply on success |
IDN territory |
en-US fallback |
Returned whether fallback was used |
I fixed the test to assert “not called” as an empty array
Testing only that a language error was returned did not show whether GET had already run internally. I used the methods array, which collected request methods, to assert that no external call occurred.
// care-mcp/tests/asc.test.ts:18-36 원문
methods = [];
const explicit = await client.reply({ review_id: 'review-1', body: '안녕하세요. 도와드리겠습니다.', locale: 'fr-FR' });
expect(explicit).toMatchObject({ ok: false, error: { code: 'LOCALE_MISMATCH' } });
expect(methods).toEqual([]);LOCALE_MISMATCH and the empty array had to appear together to show that the request was blocked before GET.
In the same file, I also fixed pagination. I followed links.next and combined the results, and used exists[publishedResponse] instead of filter[answered]. Without combining the results, the locales appeared as 10 instead of 16, and the ratings differed from the actual values. filter[answered] returns 400.
Why all authentication responses were 401: the signature was 32 bytes
Authentication came before the discussion of language. Initially, every ASC call returned 401.
ASC receives JWTs signed with ES256. Node's sign returns the signature in DER format, while JWT requires JOSE format. The difference is the length.
// care-mcp/src/asc.ts:23-32
// DER 정수는 최상위 비트가 1이면 선행 0x00 이 붙어 33바이트가 되고, 값이 작으면 32바이트보다
// 짧아진다. 따라서 선행 0을 벗기고 32바이트 슬롯에 오른쪽 정렬해야 한다.
const toFixedWidth = (value: Buffer): Buffer => {
let start = 0;
while (start < value.length - 1 && value[start] === 0) start += 1;
const trimmed = value.subarray(start);
if (trimmed.length > 32) throw new Error('ES256 서명 정수가 32바이트를 초과합니다.');
const slot = Buffer.alloc(32);
trimmed.copy(slot, 32 - trimmed.length); // 오른쪽 정렬
return slot;
};DER stores two integers together with their lengths. If the most significant bit of an integer is 1, 0x00 is prepended to distinguish the sign, making it 33 bytes. If the value is small, it can be 31 bytes instead. JOSE requires each integer to be exactly 32 bytes.
The symptoms were confusing because the length varied from signature to signature. The key was not wrong, and neither was kid. Verification failed because the signature value had the wrong number of bytes. Also, because the key created a new signature each time, some signatures could happen to be 64 bytes and pass. That was the branch that used signatureDer.length === 64 as-is.
Right alignment was the point of this function. If the value is placed on the left, it becomes 256 times larger for each byte. A short integer must be padded with zeros at the front to represent the same number.
The same kind of issue made 10 locales appear instead of 16
asc_version_locales returned 10 items. There are 16 release locales.
The ASC response did not fit on a single page. Without following links.next, I was only seeing the first page. The ratings summary also had its territory truncated, so it returned values different from the actual ones.
// care-mcp/src/asc.ts:82 — 페이지를 이어 받는다
const links = inputObject(payload.links);
const candidate = links && typeof links.next === 'string' ? links.next : undefined;Bugs that make numbers appear smaller are easy to miss. Ten is also a plausible number. I caught it because there was other evidence indicating 16.
I also fixed the filter in the same file. filter[answered] returns 400. ASC accepts exists[publishedResponse]. Guessing at a parameter based on a similar name produces a 400, and fortunately a 400 does not look like an authentication error, so I was able to distinguish it quickly.
I also hard-coded the body limit in the code. It is 5,970 characters. It appears twice: in the input schema's max(5970) and in the check before the function. Even after passing the schema, the call path counts it again.
I checked the language, calls, and ledger separately
I listed what I verified in production.
| Check | Result |
|---|---|
| Number of release locales | 16 |
FRA |
fr-FR |
JPN |
ja |
DEU |
de-DE |
BRA |
pt-BR |
IDN |
en-US, fallback: true |
asc_review_reply(locale=fr-FR) + 한국어 본문 (Korean body text) |
LOCALE_MISMATCH, 0 ASC calls |
| Success path | Recorded in care.review_reply |
I also left the fact that IDN was a fallback in the response. Replying to an Indonesian customer in English and replying in Indonesian were different facts. If I hid whether fallback occurred, I would lose the basis for adding the locale later.