A timeline of an app submission: waiting, waiting, rejected, fixed, waiting, live — with a single phone notification at the only moment that mattered.

App Review takes a day or two and tells you nothing while it runs. So you refresh App Store Connect. You refresh it on your phone in a queue. You refresh it at 11pm because maybe reviewers work late.

The App Store Connect API will tell you instead. It is a read, it is free, and a cron job can do it. The whole watcher is about sixty lines. What follows is not the sixty lines — it is the four things that broke ours, because every one of them fails silently, which is the worst way for a notifier to fail.

1. “Live” is usually two things, and either one alone means nobody can pay

Our submission had an app version and an auto-renewable subscription. The obvious check is whether the version reaches READY_FOR_SALE. That check is wrong: a version can go live while its subscription is still in review, and a subscription can be approved while the version carrying the paywall has not shipped. Apple moves them separately and not in a fixed order.

If your app sells something, the alert condition is both halves at once. Ours only texts when the version is on sale and the subscription is approved, because either on its own is a release nobody can buy from.

2. Do not read “the newest version”

The natural way to find the version you submitted is to list versions and take the first one. We nearly shipped that. In our account the list also contained version 1.3.3, sitting at READY_FOR_SALE since September — so a watcher reading the newest row would have fired the moment it started, announcing success for a release that went out a month earlier.

Pin the version by its ID. The ordering of that list is not a promise.

3. A rate limit will eat the one message that matters

Our SMS sender caps at six messages a day, deliberately — every text costs money and a notifier wired into a build loop can spend a morning’s budget in a minute. But that cap means the one message you actually care about can arrive on a day that has already spent it.

The bug this creates is subtle. The first version recorded “I have reported this” and then sent. If the send was refused, the news was already marked as delivered and never went out again. Record it as sent only after the send succeeds, and let the next tick retry. A notifier that can silently decide not to tell you is worse than no notifier, because you stop checking.

4. Test the alert, not the code

The alert path runs once, unattended, maybe forty hours after you write it. Until then it has never executed. That is the classic way this fails: everything works except the thing the whole script exists to do.

Ours takes a --simulate flag that forces each state pair, and a --dry flag that prints the message without sending. Every branch — live, rejected, passed-but-waiting — was fired before the watcher was trusted. Two bugs turned up that way, including a message long enough to split into two billed SMS segments.

Two things the API will not give you

Worth knowing before you plan around them. The API does not expose Resolution Center, so when your app is rejected you can read the state but not the reason. The reason arrives by email to the account holder. We found ours there.

And resubmitting after a rejection is awkward: the rejected submission still holds your version, so attaching it to a new one returns 409 until you cancel the old submission — and then there is a propagation delay. Our first retry failed; twenty seconds later it went through.

Keeping it alive

Ours is a systemd user timer firing every twenty minutes, which costs two API reads. Two details matter: user timers stop when you log out unless lingering is enabled (loginctl enable-linger), and the watcher records what it has already reported so the same news is never sent twice. After it fires, it stops making requests at all.

The point is not the sixty lines. It is that waiting on someone else’s queue is a solved problem, and the solution is small — as long as you check both halves, pin the version, let a failed send retry, and fire every branch before you trust it.