WizusLabs Engineering · Studio

Read back what you published: four ways the two store APIs disagree

We shipped one listing refresh to two stores in 18 languages. Both APIs accepted everything. Neither of them told us what it had actually stored — and the four places they disagree all fail in the same direction, toward a success you did not get.

By WizusLabs Engineering · 2026-07-31 · ~12 min read

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.

Two publishing models for the same listing A side-by-side schematic. Google Play treats a listing as one live resource mutated inside a transaction: insert an edit, GET each locale’s full listing resource, PUT it back as a full resource with one field substituted, then commit once for all 18 locales. The swap is atomic at commit and the result is live immediately with no release and no review. App Store Connect treats the same copy as version-scoped: the text hangs off an app version record, each localization is PATCHed as a genuine partial update, screenshots are deleted and then re-uploaded in separate non-atomic steps, and nothing serves until the version is submitted and approved. Google Play — one live listing, one transaction App Store Connect — version-scoped, staged edits.insert open one edit for all 18 locales listings.get, per locale capture the whole resource listings.update a full-resource PUT, one key reassigned edits.commit one commit — atomic live immediately: no release, no rollout, no review an app version record the copy hangs off a version, not the app PATCH appStoreVersionLocalizations a genuine partial update, per locale DELETE screenshots, then upload separate calls — not atomic submit the version then wait for a human staged until the version is approved the same sentences of copy, two incompatible meanings of “written”
Play mutates one live resource inside a transaction; Apple patches fields on a version that a human has to approve. Every disagreement in this post falls out of that gap. Schematic, not measured data.

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.

Apple’s three-step screenshot upload, and the auth trap in the middle A schematic of three sequential calls. Step one is an authenticated App Store Connect request that reserves the asset and returns upload operations. Step two is an unauthenticated AWS SigV4 presigned PUT to an Apple object- storage host, signed over the host header only, and it must carry no Authorization header at all. Step three is an authenticated App Store Connect request that marks the asset uploaded and supplies the file’s MD5 as the source file checksum. Attaching the App Store Connect bearer token to step two invalidates the signature and returns 400 Invalid request, at a point where the previous screenshots have already been deleted. Neither the 201 from step one nor the 200 from step three means the image was delivered; delivery is the asset delivery state reaching COMPLETE, which has to be polled for. one screenshot, three calls, two different authorities authenticated — ASC UNAUTHENTICATED — S3 authenticated — ASC 1 · reserve POST appScreenshots → 201 + uploadOperations Bearer JWT required 2 · upload PUT presigned SigV4 URL signed over host only send NO Authorization 3 · commit PATCH uploaded: true sourceFileChecksum = MD5 Bearer JWT required attach the ASC bearer token to step 2 and the signature is invalid 400 Invalid request — after the old screenshots are already deleted the 201 and the 200 are not delivery delivery is assetDeliveryState = COMPLETE, and you have to poll for it
Two of the three calls want the bearer token and the middle one is broken by it. The two success codes describe your requests; only a polled delivery state describes the asset. Schematic, not measured data.

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.listings reference. Supports the model: listings are edited inside an edits transaction, update is a PUT over 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, appStoreVersionLocalizations and its PATCH, appScreenshotSets and its orderable appScreenshots relationship, and the reserve / upload / commit sequence with sourceFileChecksum and assetDeliveryState. 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.

← Back to the Blog