Subscription System¶
Overview¶
Nutri-E uses a secure, server-side-only subscription management system. Subscriptions are managed entirely through Apple Server Notifications - users cannot fake purchases.
Security model:
- Apple -> Cloudflare direct: Webhooks can't be spoofed by malicious apps
- Zero-trust: App claims are ignored, only Apple's signed data trusted
- Automatic sync: Renewals, cancellations, refunds handled automatically
Architecture¶
iOS App (StoreKit 2)
|
| 1. User purchases (includes deviceId via appAccountToken)
v
Apple App Store (charges, verifies, issues receipt)
|
| 2. Apple sends webhook to Cloudflare (~1 min)
v
Apple Webhook Worker (decodes JWT, updates SUBSCRIPTIONS KV)
|
| 3. Next API request
v
OpenAI/DSLD Workers (read subscription tier, enforce quota)
Why no client-side sync? Prevents malicious apps from claiming fake subscriptions. Apple webhook can't be spoofed.
Subscription Tiers¶
| Tier | Product ID | AI Requests/Day | DSLD Lookups/Day | Price/Month |
|---|---|---|---|---|
| Free | - | 10 | 100 | Free |
| Basic | no.invotek.nutrie.basic_monthly |
10 | 100 | $4.99 |
| Pro | no.invotek.nutrie.pro_monthly |
50 | 500 | $11.99 |
| Premium | no.invotek.nutrie.premium_monthly |
160 | 2000 | $24.99 |
| Ultimate | no.invotek.nutrie.ultimate_monthly |
500 | Unlimited | $39.99 |
Purchase Flow¶
- User taps "Subscribe" in SubscriptionView
- StoreKit 2 processes purchase, passing device ID via
appAccountToken - Apple charges payment method
- StoreKitManager updates local AIUsageTracker (immediate UI feedback)
- Apple sends webhook to Cloudflare (~1 min later)
- Cloudflare decodes JWT, stores subscription in KV
- Next API request gets new quota tier automatically
Lifecycle Architecture¶
Design decision: Event-driven only. Apple webhook owns the full subscription lifecycle.
Worker Responsibilities¶
| Worker | Role | Operations |
|---|---|---|
| Apple Webhook | Source of truth | CREATE, UPDATE, DELETE, ARCHIVE |
| OpenAI | Read-only enforcement | READ tier, CHECK expiry, TREAT expired as free |
| DSLD | Read-only enforcement | Same as OpenAI |
Cancellation Flow¶
- User cancels in App Store settings
- Apple sends
DID_CHANGE_RENEWAL_STATUS(AUTO_RENEW_DISABLED) - Subscription remains active until
expiresAtdate - On expiration: Apple sends
EXPIRED, webhook archives and deletes - User reverts to free tier
Safety Net¶
If Apple webhook fails (Apple retries for 72 hours):
- OpenAI/DSLD workers check
expiresAton every request - Expired subscriptions treated as free tier
- Stale data may remain in KV but doesn't affect security
Apple Webhook Events¶
Events That Create/Update Subscriptions¶
SUBSCRIBED- New subscription or resubscribeDID_RENEW- Successful renewalOFFER_REDEEMED- Promotional offerREFUND_REVERSED- Refund reversed (restore)DID_CHANGE_RENEWAL_PREF(UPGRADE) - Immediate upgrade
Events That Delete Subscriptions¶
EXPIRED- Subscription expired (various subtypes)REFUND- Refund grantedREVOKE- Family Sharing revoked
Events That Just Log¶
DID_CHANGE_RENEWAL_STATUS(AUTO_RENEW_DISABLED) - Cancelled, still activeDID_FAIL_TO_RENEW- Billing issue, grace periodGRACE_PERIOD_EXPIRED- Grace endedTEST- Test notification
SUBSCRIPTIONS_DELETED Archive¶
Expired/refunded/revoked subscriptions are archived before deletion:
{
"plan": "pro",
"expiresAt": "2025-10-17T00:00:00.000Z",
"updatedAt": "2025-10-10T00:00:00.000Z",
"deletedAt": "2025-10-15T12:34:56.789Z",
"deleteReason": "EXPIRED_VOLUNTARY"
}
Delete reasons: EXPIRED_VOLUNTARY, EXPIRED_BILLING_RETRY, EXPIRED_PRICE_INCREASE, EXPIRED_PRODUCT_NOT_FOR_SALE, REFUND, REVOKE.
Retention: 6 months for support, 1 year for analytics.
KV Storage¶
Key: Device ID (UUID)
Value:
iOS Implementation¶
// appAccountToken passes device ID to Apple
let deviceId = DeviceAuthService.shared.getDeviceId()
let deviceUUID = UUID(uuidString: deviceId)!
let options: Set<Product.PurchaseOption> = [.appAccountToken(deviceUUID)]
let result = try await product.purchase(options: options)
App Store Connect Setup¶
- Create subscription group "AI Subscriptions" with 4 auto-renewable subscriptions
- Configure Server Notification URLs:
- Production: Apple webhook worker production URL
- Sandbox: Apple webhook worker sandbox URL
- Version: 2
Testing¶
# Verify KV storage
wrangler kv key get --namespace-id="<id>" "<device-id>"
# Test webhook
./cloudflare-worker-apple/test-apple-webhook.sh <sandbox-url> sandbox-v3
# Monitor logs
wrangler tail nutrie-apple-webhook-worker-v3 --grep "subscription"
Troubleshooting¶
Subscription not syncing: Check Cloudflare logs, device auth headers, SUBSCRIPTIONS KV.
Apple webhook not firing: Verify App Store Connect notification URL, check Cloudflare logs.
Quota not updating: Check SUBSCRIPTIONS KV has correct plan, verify worker logs show correct tier lookup.