What I was doing
I ran a watcher script to check review results on five screenshot A/B experiments. This experiment type exposes no result metrics over the API (per-arm impressions and conversions live only in the web UI), so the script does exactly two things: read state, and start anything that has cleared review.
It reported four as "startable", tried to start them, and got 409 on all four. It even printed a helpful note: the API won't start these, go start them manually in the web UI.
All of it was wrong. Those four had been running since the previous day.
One question before the details. Have you ever cross-checked the state your watcher reports against a second path to the same resource?
The real bottleneck, and the reversals
Reversal 1 — the list endpoint lies
The experiment list endpoint returns startDate and endDate as null, always. Fetch the same experiment from the detail endpoint and both timestamps are sitting right there.
list: {"state":"APPROVED","startDate":null,"endDate":null}
detail: {"state":"APPROVED","startDate":"2026-08-18T05:52:34-07:00",
"endDate":"2026-11-16T04:52:34-08:00"}From the list alone, it looks like an experiment that hasn't started. I read null as "no value yet." What it actually meant was "this path doesn't populate this field."
Reversal 2 — there was nothing to start in the first place
These experiments start automatically once review passes. Which is why all three attempts fail:
PATCH .../{eid} {"attributes":{"state":"IN_PROGRESS"}}
→ 409 ENTITY_ERROR.ATTRIBUTE.NOT_ALLOWED
"The attribute 'state' can not be included in a 'UPDATE' operation"
PATCH .../{eid} {"attributes":{"startDate":"..."}}
→ 409 same code, startDate
PATCH .../{eid} {"attributes":{"started":true}}
→ 409 STATE_ERROR "Can't start experiment, it's already running"Three different errors were stating the same fact. The script saw the first two, concluded "permissions or wrong path", and never attempted the third — the only one that spelled out the truth in a sentence.
Pause here. What would you do after two 409s? I went with "this API can't do it" and printed manual instructions. That was the wrong move. A 409 is not "forbidden", it is "state conflict" — and a state conflict can mean the state you believe in is the thing that's wrong.
Reversal 3 — the else branch in the state classifier
The code split states into pending / running / finished, and let "anything else" fall through to startable.
So it tried to start experiments that review had rejected. An else that treats unenumerated states as normal takes a wrong action silently. No error, and the log looks plausible.
What this actually cost
One experiment had been running for 24 hours while the dashboard, the script, and I all believed it had not started.
When your observability points at the wrong thing, that time simply disappears. You could argue no harm was done — the experiment was collecting data fine — but I spent a day on "why isn't this running." I have hit the same class of illusion in a column labelled 28 days that was summing 118 and in two months judged on numbers inflated 3×. The cause differs every time; the shape does not. The numbers weren't wrong — my assumption about what they counted was.
The fix
# the list endpoint always returns startDate/endDate as null -> enrich from detail
det = requests.get(f"{API}/v2/appStoreVersionExperiments/{eid}", headers=H())
if det.status_code < 400:
a.update(det.json()["data"]["attributes"])
# the platform starts it automatically on approval -> APPROVED + startDate means running
if state in RUNNING or (state == "APPROVED" and a.get("startDate")):
...
# enumerate the rejected states and report them. an else here tries to start them
REJECTED = {"REJECTED", "DEVELOPER_REJECTED", "REMOVED_FROM_REVIEW"}Same script, same experiment, before and after:
before: start failed state=APPROVED - state PATCH 409, startDate PATCH 409 - start manually in UI
after : running (start 2026-08-18, APPROVED) verdict due 2026-09-03 (+30% detectable)One experiment gets a warning because its verdict date collides with its automatic end date:
running (start 2026-08-18) verdict due 2026-11-16 [!] collides with auto-end 2026-11-16 - read results before it endsThree things to check on your own build
- Does the endpoint your watcher reads actually populate the field it reads? Fetch the same resource by a second path and compare. That is what separates "null means absent" from "null means this path won't tell you."
- What does the else branch of your state classifier do? If it takes an action for unenumerated states, that is where it goes quietly wrong. Let else report, not act.
- Are you reading error messages all the way to the end? I bundled two 409s into "unsupported API" and never pressed the third. The third was the answer.
The honest part
- When I first wrote the script I trusted the list response as-is. I read a null field as "no value yet" and never fetched the same resource by a second path to compare. That is not a trust problem, it's a skipped verification.
- I never confirmed the auto-start behavior when I created the experiments. I had even left myself a note saying "submission alone won't run it, you have to start it after approval" — a guess with nothing behind it. A wrong note is worse than no note: the next session reads it as fact.
- Still unfixed: result metrics remain absent from the API. Miss the verdict date and you're digging through the web UI. One experiment's required window (90 days) lands on the same day as its automatic end, leaving zero slack. Attaching a warning in code was all I could do about it.
Conclusion
On the same day, from the same batch of experiments, a rejection reason also sent me to the wrong place — that one is written up in the free trial was a price. Twice in one day, my own tools told me something false.
Pick one watcher script you have running and cross-check the fields it reads against a detail fetch, just once. I got around to that 24 hours late.