Every write we made to both stores came back successful, and not one of those responses was evidence about the listing. A success response is a statement about your request, not about the store’s state. The two publishing APIs disagree about what a write even means in at least four ways — what a body replaces, when a field is writable, what order is kept, and what is atomic with what — and none of those disagreements announces itself as an error. Each hands you an acknowledgement you were entitled to read as done.
Two APIs, one listing, and no shared idea of what a write means
The job was ordinary: rewrite one game’s store copy, then push it to Google Play and the App Store in 18 locales. Which store calls a field a name and which calls it a title is already written up in one codebase, two stores, and that post signs off by handing “the tooling you build yourself” to somebody else. This is that tooling, and it turns out to be almost entirely about reading rather than writing.
The two APIs are built on different models of a listing, and the
models leak. Play gives you a transaction: open an edit, mutate resources inside it, commit once,
and the copy is live with no review. Apple gives you a version: the text hangs off an app version,
patched field by field, staged until a human approves it. Neither is wrong. But every convenience
one offers is a convention the other does not share, and a mismatched convention never surfaces
as an error — it surfaces as a 200.
The disagreement starts before you reach a field. The same app is
com.wizuslabs.sudokuGame to Apple and com.wizuslabs.sudoku_game to Play,
and the iOS bundle id against the Play API returns Package not found. Both stores
carry 18 locales for this app and they are not the same 18. Our own brief warned that
Apple’s codes would not match our repository’s directory names; they matched exactly,
which anyone knows only because the mapping was built from what the API returned and checked
before the first write.
The PUT that would have undone the fix in the act of shipping it
Play’s edits.listings.update is spelled like an
update and behaves like a replacement. It is a full-resource PUT: send a body carrying only
fullDescription and that locale’s title,
shortDescription and video go blank — not rejected, not warned
about, blank, with a success code, because you did exactly what you asked for. The whole point of
the job was that the long descriptions had been rewritten to match the localized titles. The naive
call would have wiped the titles the new copy had just been written to agree with, in 18
languages, and reported success 18 times.
We never let that happen, for a boring reason: the hazard was named in the dispatch brief before the first write. So the implementation did GET-then-PUT — read the full listing resource, re-read it inside the writing edit and abort if any of the three fields had drifted, then PUT back the response object with exactly one key reassigned. The preserved fields were never re-typed and never reconstructed from the repository, because a field you reconstruct is a field you can get wrong. All three came back byte-identical to the baseline in 18 of 18 locales.
We are not going to dress that up as a war story. Nothing was blanked, and we did not reproduce the blanking to prove it — a mechanism you have read about is not one you have measured. What the episode shows is cheaper than a disaster would have been: the mitigation cost one extra GET per locale, and it existed only because somebody wrote the hazard down before the code did.
Which field you touch decides whether you can touch it at all
This is the strongest thing in our record, because it is the one place
where we were wrong in writing, found out, and later got a measurement that explained why. We had
characterised Apple’s App Info fields — the app’s name and
subtitle — as app-scoped and editable live. The brief refused to accept it and
demanded a probe, so the probe was built as a semantic no-op: take en-US, read the
current store bytes, PATCH them straight back. Writing a field’s own value to itself changes
nothing, which makes it the safest question you can ask an API.
It came back
409 ENTITY_ERROR.ATTRIBUTE.INVALID.INVALID_STATE:
“The field ‘name’ can not be modified in the current state”
— twice, once per field. Meanwhile promotionalText, which lives on the live
version, PATCHed cleanly in all 18. A GET explained it:
/v1/apps/{id}/appInfos returned exactly one record, and Apple exposes a
second editable App Info only alongside an editable app version. The confirmation came later,
which is what turns a diagnosis into evidence: once the 1.0.6 version record existed
that GET returned two, and the identical PATCHes went 17 × 200 OK.
App-scoped does not imply live-editable, and only a 409 was ever going to say so.
That is adjacent to an argument we made before, not the same one. In valid does not mean ready the culprit was one console status field scoped narrower than readers assume — a single surprise per app record, learnable once. Here the acknowledgement is decoupled from the stored state by construction, so it recurs on every publish and multiplies by locale: 18 responses, each true about a request and silent about a listing.
The order you upload is not the order Apple keeps
Screenshots are a narrative: frame one earns the tap, frame two proves
the claim, frame six closes. The sequence is the design, and it is the property the API is least
interested in preserving. Before touching anything we read Apple’s two existing live sets and
both came back out of order — the iPad set as 4,5,1,2,3,6, the iPhone set as
3,4,1,2,5,6. Order has to be set explicitly with
PATCH /v1/appScreenshotSets/{id}/relationships/appScreenshots, which returned
204 for both sets, and then read back rather than assumed, because 204
is the emptiest promise in HTTP.
Two corrections, both ours, because the tidy version of that anecdote is wrong twice. There is no single scramble: our own notes generalised the iPad permutation to both sets. And those were the pre-existing live sets, caught by a guard that runs before any mutation — not a set we had just uploaded in numbered order and watched get shuffled. Weaker claim, correct one, and the more useful one: the disorder was already in production, from an upload nobody had read back.
The asymmetry is lopsided, then. Apple’s scrambling we measured, twice; Play’s preservation we never did, and the reason is worth stating plainly: our Play verification compared the asset set by sha1 membership rather than by sequence, so the check we ran could not have answered the question and nothing ever forced us to ask it. Treat that as a documented baseline, not the measured half of a measured contrast. At the strength the evidence supports: we have a measurement of Apple’s ordering and an untested assumption about Play’s, and those are not the same kind of thing.
The unauthenticated step in the middle of an authenticated flow
Uploading one screenshot to App Store Connect is three calls, and the
middle one is not Apple’s. Step one reserves the asset on ASC and returns a set of upload
operations. Step three confirms it on ASC, carrying the file’s MD5 as
sourceFileChecksum. Step two is an unauthenticated AWS SigV4 presigned
PUT to northamerica-1.object-storage.apple.com, signed over
X-Amz-SignedHeaders=host — so attaching the ASC bearer token, which every other
call in the flow requires, invalidates the signature.
Which is what happened, and it is the sharpest thing we actually hit
rather than prevented. Our shared HTTP helper added Authorization: Bearer to every
request, because that is what a shared HTTP helper is for. The first PUT failed
400 Invalid request — after the six iPhone deletes had already committed, in the
one state that matters. The fix was a dedicated put_asset() sending only the
operation’s own prescribed headers, verbatim: a lone Content-Type: image/png, no
Authorization at all. The obvious spelling of a generic API client is the wrong one, and
the failure is invisible until that first PUT — by which time you have
deleted something.
The same flow carries the cleanest statement of this post’s
thesis anywhere in Apple’s API. A 201 from step one is a reservation. A
200 from step three is an acknowledgement that you claimed the upload
finished. Neither is delivery. Delivery is assetDeliveryState reaching
COMPLETE — a fourth call that nothing in the first three obliges you to
make.
Reading it back is easy. Comparing it on the right basis is not
Deciding to verify is the small half. Both stores hand back a string
you can compare with the file you sent, and the two need different equality tests on the same
bytes. Play drops the POSIX trailing newline, and drops it on persist rather than in transit:
the PUT echoed de-DE back at 3,942 characters and the committed listing returned
3,941. The caller cannot suppress it, so the Play comparison runs against the file minus its final
byte. Apple keeps that byte — en-US came back at exactly 3,475 characters,
matching the raw file and failing the minus-newline test. Carry Play’s rule across
to Apple and you get 0 of 18 on a flawless upload: a fabricated catastrophe from a check that was
working perfectly and asking the wrong question.
Assets have the same problem one layer down. Verify
store images by content hash, never by count, size or filename. In Play’s phone bucket the
frame 08-relaxed.png read the same unchanged file size before and after the refresh
— same name, same size, different hash. A size- or name-based check would have called it
identical and skipped the upload, leaving a stale frame live while the tooling reported a clean
pass. Overlap between the new eight hashes and the previous live set was 0 of 8, which is how you
know a refresh was a real replacement rather than an append.
And the units are not what you would guess. Store text
limits count characters, not bytes. Against Apple’s documented 30-character subtitle ceiling
all 18 of our subtitles fit, three at exactly 30; measure the same 18 in bytes against 30
and 9 fail — Hindi is 28 characters and 76 bytes, Thai 25 and 73, Russian 27 and 52. That is
a counterfactual computed from our own files, not a rejection anyone handed us, and short fields
concentrate the multiplier instead of diluting it. The audit greps inherit the same trouble: the
cross-platform scan argued for in
never name the other store uses word
boundaries, and Hangul and Devanagari are Unicode word characters, so a
\bWizusLabs\b control reads 17 of 18 and looks exactly like an off-by-one defect.
Atomicity is not a property the two stores share
Replacing a screenshot set means removing what is there and putting
something else in its place, and the question that matters is what a shopper sees in between. On
Play, nothing: the delete and the six uploads live inside one edit —
images.deleteall takes the bucket from six to zero, six uploads take it back, and a
single edits.commit lands the swap. So the live listing never drops below
Play’s documented two-screenshot publishability floor. That follows from the transaction
model and held across two clean swaps; nobody polled the listing mid-edit, and we will not claim
they did.
Apple gives you no transaction to put the delete and the upload inside,
and the 400 from the previous section landed after six deletes had committed, so the
set was genuinely empty for a window. It was recovered in the same dispatch, and the stranded
reservation sitting in AWAITING_UPLOAD was purged before the retry so no orphan
survived. Apple requires both an iPhone and an iPad set to be present, so an emptied set is a
state that would block a submission — a consequence of the documented requirement,
not something we demonstrated, because nobody tried to submit in that window.
What we do now: read the store back, with a request of its own
The discipline is one sentence with three parts. After every write, read the resource back through an independent request — never the write’s own response body, which is the API telling you what it thinks you sent. Compare on the basis that store actually uses. And read before the write, because most of what goes wrong is a premise rather than a payload.
The pre-flight half is cheapest, and it prevented the worst outcome in
the project. fastlane supply resolves Play release notes by filename:
changelogs/<versionCode>.txt. Our production cut was versionCode 140 of
marketing version 1.0.6 — a re-cut, which bumps the build number and leaves the marketing
version alone, so the authored notes sat in 139.txt across all 18 locales and no
140.txt existed anywhere. supply would have attached nothing: a
production release, to every user, with a blank “What’s new” in every language,
and a successful promote to say it went fine. The guard was one ls. We used the direct
Play API instead, then re-read from a fresh edit: production at 140,
completed, notes in all 18 locales.
The other habit worth stealing is the pilot of one. Apple’s
PATCH /v1/appStoreVersionLocalizations/{id} genuinely is a partial update, but we did
not take the verb’s word for it — a full replacement wearing a PATCH label
is exactly the shape one store over. en-US went first, patched with
description alone and read back through an independent GET: the description replaced,
digest matching the repository file, the five sibling fields byte-identical. Only then did the
other 17 go.
Yesterday we published the mirror image of this. In every zero has two explanations the lie comes from the reader: a check reads clean because the instrument never ran. Here it comes from the writer — the instrument ran perfectly and reported faithfully on a request, and the request was never the thing you cared about. Any signal that can be produced without the world changing is not evidence that the world changed.
None of these four disagreements is a bug, and we would not want either API changed. They are two coherent models of what a listing is, and the cost of that coherence is paid at the seam by whoever holds both. What that person owes the listing is not more caution — it is one more request. A success response is a statement about your request, not about the store’s state, so read back what you published.
Notes
This is a first-hand account of one job — a single game’s store copy and screenshots refreshed across 18 locales on both stores, from our own release records as they stood on 2026-07-31. It carries no performance, revenue or install figures, deliberately: a post arguing that acknowledgements are not evidence has no business carrying an unverifiable one.
Documented versus measured, because it bounds everything above. Every
locale code, character count, image count and track state came out of the two APIs; the
limits did not. Play’s API exposes no limits endpoint, and Apple’s image
dimensions and subtitle ceiling come from published specification, so we tested our copy against
those numbers rather than the numbers themselves. Three of the four failure modes here are
near-misses rather than incidents, which is the point rather than a hole in it: no title was ever
blanked, fastlane supply was never invoked on that promote, and the newline mismatch
was caught before the comparison ran.
Where it ended up, precisely. On Play, versionCode 140 of 1.0.6 is live
in production at full rollout with notes in all 18 locales, and the listing text and screenshots
went live with no submission and no review because those surfaces are live-editable. On the App
Store, 1.0.6 was submitted for review on 2026-07-30 and was still WAITING_FOR_REVIEW
when these records closed — staged, not approved, not serving. The app is
Sudoku by WizusLabs, and like the rest of our games it is
free and ad-supported with one optional Pro purchase that removes the ads. We are not claiming an
ad-free product, because that would be false.
Sources
- Google
Play Developer API —
edits.listingsreference. Supports the model: listings are edited inside aneditstransaction,updateis aPUTover the whole resource, and changes reach the store on commit. It does not document the trailing-newline strip, which is our measurement. - App
Store Connect API reference. Supports the resources and verbs named above —
appInfos,appStoreVersionLocalizationsand itsPATCH,appScreenshotSetsand its orderableappScreenshotsrelationship, and the reserve / upload / commit sequence withsourceFileChecksumandassetDeliveryState. The status codes and error codes we quote are our own observations, not documented promises. - App Store Connect Help — screenshot specifications. Supports the required display families and the accepted image dimensions, which we treated as published constraints to design against. We probed none of them, and nothing of ours was ever rejected on this basis.
Keep reading: all posts on the WizusLabs Engineering blog.