Blog

August 15, 2026 · 11 min read

StoreKit Subscription Sync: Six Paths Where Server and App Disagreed

One symptom — pro in the console, free in the app — led to nine defects. Six server-side read paths collapse into one, and expiry is decided at read time instead of by webhook.

  • StoreKit
  • subscriptions
  • App Store
  • Go

It appeared as pro in the console but as free in the app.

I followed the decision path to fix one symptom. It revealed nine defects.

The server returned the correct value, and the app discarded it

I suspected the server first. The server was working correctly.

entitlementResponse was returning the tier of the active subscription regardless of the payment method. The problem was in the app.

There was a comment in the app's state-merging code.

Add usage only; leave the tier as determined by StoreKit

The code had an intentional rationale. Even if the server response was inconsistent, it would not reduce permissions recognized by Apple. This prevented a user who had paid from becoming free because of a server error.

This defense only considered one direction.

Subscriptions granted manually by an administrator leave no trace in StoreKit. That was expected because they did not go through Apple.

So even when the server returned pro, the app used the StoreKit result as-is. StoreKit had nothing, so it remained free forever.

The fix was to open up one more direction.

Before After
Server pro, StoreKit pro pro pro
Server free, StoreKit pro pro (intended defense) pro
Server pro, no StoreKit free (bug) pro

I chose the higher of the two. The original intent—"do not reduce Apple permissions"—remained intact, while permissions known only to the server also remained effective.

There were six paths for reading subscriptions on the server

After fixing the app, I searched through the server. There were six independent paths that read the subscriptions table.

Path What it determines
entitlementResponse Tier returned to the app
ResolveMessageLimit Message count limit
tierQuota Storage capacity limit
ocrTierEntitlement OCR usage permission
buildAdminMember Member tier displayed in the console
Subscription details Subscription panel in the console

Five of the six fetched the first row without an order.

There was no problem when an account had only one subscription row. In practice, there could be several.

There was a gap in the unique index

The index was on (provider, original_transaction_id). Apple subscriptions had this value, so duplicates were prevented.

Manually granted rows had an empty original_transaction_id. The index could not prevent them.

As a result, a manual subscription and an Apple subscription could be active at the same time.

If the first row was fetched without an order in this state, each path could see a different row.

entitlementResponse → 수동 행(pro) → 앱은 pro
tierQuota          → Apple 행(free) → 저장 한도는 free

The response was pro, but actual enforcement was free. This was a reachable state.

I consolidated the decision into one function

I created quota.EffectiveSubscription and made all six paths call it.

It performed three steps.

1. status가 active이거나 in_grace인 행을 전부 로드합니다 (첫 행이 아니라 전부)
2. current_period_end가 지난 것을 걸러냅니다
3. tier.sort → quota_bytes → updated_at 순으로 순위를 매겨 하나를 고릅니다

The second step was a new defense. The third made the result deterministic.

There was one additional condition in the third step. Rows whose tier relationship was broken were not excluded from the candidates; they were placed below every row with a tier. A user would not lose permissions because of a corrupted row, and a valid row would win whenever one existed.

The ranking function was twelve lines long. It examined the next key only when the preceding keys were equal.

Ranking key Comparison What it guarantees
Whether the tier relationship exists The one with the relationship wins A corrupted row does not displace a valid row
tier.sort Higher wins The higher tier wins
quota_bytes Higher wins For the same tier, the one with greater capacity wins
updated_at More recent If all of the above are equal, the more recently changed row wins

The final key made the result unique. Even if two rows matched on the first three keys, their order did not fluctuate.

No matter how many rows an account had, and regardless of which path called it, the same row was returned.

I made one exception for the console details. It fell back to the most recent row only when there was no valid subscription. Otherwise, the subscription panel would appear empty for members with an expired subscription.

I also added conditions to the fallback so that the member tier and subscription panel could not point to different rows.

I changed expiration to be determined at read time

The second step, comparing current_period_end, was something no one had done before.

The only code that handled expiration was Apple's server notification handler. When Apple reported that "this subscription had ended," it changed the status.

There were two problems with this.

If a webhook was lost, the subscription remained pro forever. If the notification did not arrive, the status stayed as it was. Whether because of a network problem or a server restart at the wrong time, missing one notification left the account paid forever.

A manually granted subscription with an expiration date never ended. Even if an administrator granted it for "one month only," no Apple notification would ever arrive. No one checked it after the expiration date passed.

The fix was to compare the date on every read.

읽기 필터: current_period_end < now() 인 행은 유효하지 않음

There was also a cron job that cleaned up the status of expired rows.

The roles were different.

Role
Read filter Actual defense. Always accurate
cron Cleanup. Aligns the status column with reality

The decision remained correct even if cron failed. Conversely, if cron were treated as the defense, everything would become paid while cron was down.

Cron ran at 1:30 a.m. The other cleanup jobs registered in the same file ran at 3:00 and 3:30 a.m., and shared expiration ran every 15 minutes. I spaced them out so their times would not overlap.

The grace state reverses this rule

There was one exception. When Apple sent a GRACE_PERIOD notification, the status became in_grace.

That notification arrived because the billing period had ended and the renewal payment had failed. At the moment the status was written, current_period_end was already in the past. If I applied the read-time expiration rule as-is, I would cut off the permissions of exactly the user Apple was asking us to "continue serving while the card is retried."

So in_grace survived the passed period. Instead, I imposed an upper bound: 28 days from the end of the period.

The reasoning for choosing 28 days was in a comment. The grace periods configurable in App Store Connect were 3, 16, and 28 days. Regardless of our app's setting, the largest value that would not cut off a legitimate grace period was 28 days. Choosing the middle value of 16 days would cut off the grace period of an account configured for 28 days 12 days early.

Without an upper bound, an account that missed a single GRACE_PERIOD_EXPIRED notification would remain paid forever. I could not revive in an exception the same problem this rule had been created to avoid trusting webhooks.

I made manual grants reversible

Administrators could grant subscriptions manually. There had been no way to undo them.

Once granted, the database had to be edited directly. If a subscription had been granted to the wrong account by mistake, someone had to write SQL.

I created an admin subscription revoke endpoint.

It did not delete the row. It changed it to revoked.

I also paired the audit logs. Since MANUAL_GRANT was recorded when granting, MANUAL_REVOKE was recorded when revoking.

Deleting the row would erase the record. It needed to remain clear who granted and revoked it, and when.

Five places on the app side were also inconsistent with the server

After cleaning up the server, I looked at the rest of the app. Five issues emerged.

Offline downgrade was asymmetric

When the server response failed, the app fell back to the StoreKit decision.

Apple subscribers had no problem. Their StoreKit record kept them at pro.

Only manual subscribers fell to free. They had no StoreKit record.

In airplane mode, users losing paid features was limited to users of a particular payment method.

I changed it to remember the last server decision at the account level and retain it when the server failed.

I revalidated the StoreKit record with the server immediately after login

When a user subscribed with their Apple ID and logged in with our account, the two identities met.

I established the policy: subscriptions follow the Apple ID.

So immediately after login, the app sent the StoreKit transaction to the server for revalidation. The Apple ID's subscription was also registered as that account's server-side entitlement.

Without this, the subscription would not follow when the user changed devices or recreated the account.

The widget cache was written only when the tier changed

The widget ran outside the app process. It read the cache.

The cache was written only when the tier changed. If the tier stayed the same, it was not written.

The cache had a seven-day freshness check. After seven days, it was considered invalid and fell back to free. This prevented a device that had been offline for a long time from continuing to show an expired paid tier.

A stable user's widget became free after seven days because the tier had not changed. Since nothing had happened, the cache became stale.

I changed it to write on every successful decision. I left the freshness check in place. The widget could not query StoreKit directly, so the only option was for it to read the result decided by the app from the App Group.

The share sheet path bypassed the count gate

The free tier had a limit on the number of stored items. The normal path passed through the gate.

The path that saved from the share sheet called a different function. It did not pass through the gate.

A free account could successfully save more than 100 items through the share sheet.

I unified the paths to use the same gate.

The static cache had no account scope

The tier decision was cached in a static variable. Account information was not part of the cache key.

If a user logged in as account A, received a pro decision, logged out, and then logged in as account B, B used A's tier.

I changed it to invalidate on logout and account switching.

I kept the fallback that opened features as pro on failure. This was the existing decision not to block functionality when the entitlement could not be determined.

One symptom brought eight more with it

It began with one issue: "The console is pro, but the app is free."

I could have fixed only that one. It was a single line in the merge logic.

Following the path found the other eight. Some of them had not reached users yet.

Defect Had it reached users?
Manual subscription appeared as free in the app Reached users (reported)
Six paths returned different rows Reachable, unreported
Subscription remained pro forever if a webhook was lost Reachable, unreported
Manually granted subscription with an expiration date never ended Reachable, unreported
Manual grant could not be revoked Operational issue
Asymmetric offline downgrade Reachable
Widget became free after seven days Reachable
Share sheet bypassed the count limit Reachable
Static cache leaked across accounts Reachable

One was reported, and no one had yet experienced the other eight.

When one symptom appears, fixing only that symptom means the other eight will each appear someday. Each of them would then require another investigation.

The root of the problem was that the same decision was being made in six places.