Skip to content

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

  1. User taps "Subscribe" in SubscriptionView
  2. StoreKit 2 processes purchase, passing device ID via appAccountToken
  3. Apple charges payment method
  4. StoreKitManager updates local AIUsageTracker (immediate UI feedback)
  5. Apple sends webhook to Cloudflare (~1 min later)
  6. Cloudflare decodes JWT, stores subscription in KV
  7. 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

  1. User cancels in App Store settings
  2. Apple sends DID_CHANGE_RENEWAL_STATUS (AUTO_RENEW_DISABLED)
  3. Subscription remains active until expiresAt date
  4. On expiration: Apple sends EXPIRED, webhook archives and deletes
  5. User reverts to free tier

Safety Net

If Apple webhook fails (Apple retries for 72 hours):

  • OpenAI/DSLD workers check expiresAt on 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 resubscribe
  • DID_RENEW - Successful renewal
  • OFFER_REDEEMED - Promotional offer
  • REFUND_REVERSED - Refund reversed (restore)
  • DID_CHANGE_RENEWAL_PREF (UPGRADE) - Immediate upgrade

Events That Delete Subscriptions

  • EXPIRED - Subscription expired (various subtypes)
  • REFUND - Refund granted
  • REVOKE - Family Sharing revoked

Events That Just Log

  • DID_CHANGE_RENEWAL_STATUS (AUTO_RENEW_DISABLED) - Cancelled, still active
  • DID_FAIL_TO_RENEW - Billing issue, grace period
  • GRACE_PERIOD_EXPIRED - Grace ended
  • TEST - 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:

{
  "plan": "pro",
  "expiresAt": "2025-11-07T00:00:00Z",
  "updatedAt": "2025-10-07T20:00:00Z"
}

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

  1. Create subscription group "AI Subscriptions" with 4 auto-renewable subscriptions
  2. Configure Server Notification URLs:
  3. Production: Apple webhook worker production URL
  4. Sandbox: Apple webhook worker sandbox URL
  5. 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.