In-app purchase character limits and product ID rules, both stores

The limits nobody documents in one place:

Field Store Limit Shown to buyers
Subscription display name Apple 30 Yes
Subscription group display name Apple 30 Yes
Description Apple 45 Yes, in the App Store subscription sheet
Base plan / product title Play 55 Yes
Description Play 80 Yes

Apple's 45-character description is the one that ruins plans. It is shorter than this sentence, it is mandatory on every language you localise, and a missing one keeps the product stuck in MISSING_METADATA no matter how complete everything else is.

Note that Apple's 30 applies twice: once to the subscription itself ("Pro Monthly") and once to the group display name that sits above it ("Pro"). Two separate fields, same cap, both customer-facing.

Product ID format, the version that works on both stores

Apple product IDs accept [A-Za-z0-9._]. Play product IDs are stricter: lowercase alphanumerics with underscores and dots. If you want one ID that is valid on both stores, and you do, you take the intersection.

Two patterns, in regex form:

Purpose Pattern Max length Must start with
Shared product code /^[a-z][a-z0-9_.]{0,38}$/ 39 a lowercase letter
Per-plan SKU ID /^[a-z0-9][a-z0-9_.]{0,63}$/ 64 a lowercase letter or digit

Read the length off the regex rather than trusting a blog: {0,38} after one required character is 39 characters total, and {0,63} after one is 64.

In plain English:

  • Lowercase only. Apple will happily take Pro_Monthly; Play will not. Uppercase is the single most common cross-store ID failure, and it is unfixable later because product IDs are permanent.
  • Letters, digits, underscores and dots. No hyphens. No spaces. No +, no :.
  • Start with a letter for a product code. A digit-first ID passes the SKU pattern but not the code pattern, so 2024_pro is a legal SKU and an illegal product code.
  • Permanent. Neither store lets you rename a product ID, and neither lets you reuse one after deletion. Choose as if you are choosing a database primary key, because that is what it is.

The naming convention that survives contact with both stores is code.slug: a product code for the thing being sold and a period slug for the duration.

pro.weekly
pro.monthly
pro.quarterly
pro.biannual
pro.yearly

This is not decoration. Given pro.monthly and pro.yearly you can derive the shared product code pro mechanically by taking everything before the final dot, which is how imported products get grouped without a human deciding. Flat IDs like promonthly1 and yearlypro_v2 cannot be grouped by anything except memory.

Periods: the cross-store enum table

Play speaks ISO 8601 durations natively. Apple uses its own enum. The mapping is exact and there are only five rows worth supporting, because five is what both stores can express:

Period ISO 8601 (Play) Apple enum Suggested ID slug
Weekly P1W ONE_WEEK weekly
Monthly P1M ONE_MONTH monthly
Every 3 months P3M THREE_MONTHS quarterly
Every 6 months P6M SIX_MONTHS biannual
Yearly P1Y ONE_YEAR yearly

The billing period is immutable on a Play base plan. If you create a base plan as monthly and later decide it should be quarterly, you create a new base plan; you do not edit the old one. That makes the ISO period worth double-checking before you press create, because the wrong value is a permanent extra row in your paywall.

Trials: the same enum names, different meanings

Free trial durations use a second set of values, and Apple reuses two enum names it already used for billing periods. This is the trap:

Trial ISO 8601 Apple enum
3 days P3D THREE_DAYS
7 days P7D ONE_WEEK
14 days P14D TWO_WEEKS
30 days P1M ONE_MONTH

Look at rows two and four against the period table. ONE_WEEK is both the weekly billing period and the seven-day trial. ONE_MONTH is both the monthly billing period and the thirty-day trial. P1M is both a monthly subscription and a thirty-day free trial.

The values are only unambiguous in context. Reading ONE_MONTH out of an API response tells you nothing until you know whether you are looking at a subscriptionPeriod or an offer's trial duration. Any code that maps these with one shared lookup table will eventually turn a monthly plan into a thirty-day trial.

One more wrinkle: Play may echo a seven-day trial back to you as P1W rather than the P7D you sent. Both mean seven days. Round-trip comparisons that treat them as different values will report a drift that does not exist.

Localised names, and the 30-character squeeze

If you build subscription names by appending the period word to a base name, the German translation is what breaks it. "Pro" plus "Monatlich" is 13 characters and fine. Longer base names plus longer period words are not:

Locale Weekly Monthly Yearly
en-US Weekly Monthly Yearly
de-DE Wöchentlich Monatlich Jährlich
fr-FR Hebdomadaire Mensuel Annuel
es-ES Semanal Mensual Anual
it Settimanale Mensile Annuale
pt-BR Semanal Mensal Anual
nl-NL Wekelijks Maandelijks Jaarlijks
sv Veckovis Månadsvis Årsvis
ja 週間 月間 年間
ko 주간 월간 연간

"Hebdomadaire" is 12 characters before you have named your product. A base name of 18 characters or more overflows the 30-character cap in French, and the only graceful degradation is dropping the period word and showing the base name alone. Design for that rather than discovering it.

Note that these are Apple locale codes, not Play ones. Apple says it, ja, ko and sv; Play says it-IT, ja-JP, ko-KR and sv-SE. The same subscription localised on both stores is keyed differently on each.

What Apple wants before App Review will look at a product

Apple's checklist for moving a subscription out of MISSING_METADATA is short and every item is a hard requirement:

  1. At least one localisation, and every localisation has a display name.
  2. Every localisation has a description. One language missing 45 characters of description holds the whole product.
  3. A price exists.
  4. Available in at least one territory.
  5. A review screenshot is on file.
  6. A review note, which is optional but is where you explain how a reviewer reaches your paywall.

Number two is the one that costs days. Apple does not tell you which language is incomplete in the state field, it just reports MISSING_METADATA, so a nine-language product with one missing description looks identical to one with nothing filled in at all.

Apple's own state field also lags a submission. The product version flips immediately while the legacy state takes a while to catch up, so a product that reads READY_TO_SUBMIT after you submitted it is not necessarily still waiting for you.

AppSubmit lints these limits and the ID patterns before it creates anything on either store, and shows which locale is missing which field rather than repeating Apple's MISSING_METADATA. Everything above is checkable by hand from the tables.

Last reviewed 2026-08-17. Apple and Google change their rules without notice, so check anything decision-critical against their live documentation.