RORK LABJP
TOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude CodeSIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App StoreMAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessageNATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core MLSEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the roundTOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude CodeSIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App StoreMAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessageNATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core MLSEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the round
Articles/Dev Tools
Dev Tools/2026-05-20Advanced

Rork × StoreKit 2 × App Store Server API — A Three-Layer Subscription Architecture for Indie Apps

How to combine StoreKit 2 and Apple's App Store Server API to protect subscription revenue in Rork iOS apps with three coordinated layers: client verification, server-side JWS validation, and notification reconciliation.

StoreKit 216App Store Server API4Subscriptions15Rork515Server VerificationJWS3

Premium Article

The scariest stretch for a Rork-built iOS app that just shipped subscriptions is the first three weeks. The "active subscribers" line on your daily dashboard quietly drifts down overnight, and support tickets arrive saying "I paid, but the app says I haven't." For an indie developer, you almost never have enough material to triage what's going wrong.

I've been shipping in-app purchases for a dozen years, and after getting burned by StoreKit 1 receipt validation, I now insist on a three-layer architecture from day one with StoreKit 2: client verification, server-side JWS validation, and notification reconciliation. This article documents how I wire those three layers to a Rork-generated React Native + Expo project, using Apple's recommended App Store Server API and App Store Server Notifications V2, in a way that won't fall over at indie scale.

I focus on the parts the official docs don't quite spell out — the x5c header pitfall, how often RENEWAL notifications get delivered twice, when not to trust signedTransactionInfo — with code and numbers from real production logs.

Why three layers? The places where client-only verification breaks

StoreKit 2's pitch is that you can read Transaction.currentEntitlements on the client and know exactly what the user owns. The single-source-of-truth design is elegant, but in production it unravels in three predictable places.

The first is cross-device restore. When a family member installs the app on a shared iPad with the same Apple ID, currentEntitlements is empty until the next sign-in handshake. Without a server-side record saying "this Apple ID has an active sub," the user just sees a missing purchase.

The second is refund detection lag. When Apple grants a refund, Transaction.revocationReason only updates on the next app launch or background fetch. If you don't subscribe to REFUND notifications and revoke entitlements immediately on the server, refunded users keep their premium features for days. In a 2024 app of mine, ignoring this kept the refund rate around 1.8%; after adding server verification it stabilized under 0.6%.

The third is revenue data integrity. If your MRR comes from client-emitted events, a modified client can inflate the number with fake Transaction payloads. The financial impact is small, but losing trust in your monthly numbers degrades decision-making fast.

To plug all three gaps, you build three layers that play different roles: (1) read Transaction.verificationResult on the client to update the UI instantly, (2) ship the jwsRepresentation to your server and re-verify against Apple's public key, and (3) react to App Store Server Notifications V2 to maintain the source-of-truth state.

Layer 1: Handling verificationResult on the client

Inside the React Native project Rork generates, drop in expo-iap or react-native-iap and forward jwsRepresentation to your backend right after purchase. The mindset to commit to: the client is helping, not deciding — the final ruling belongs to the server.

// src/billing/purchaseFlow.ts
import * as IAP from 'expo-iap';
import { apiPost } from '@/lib/api';
 
export async function purchaseSubscription(productId: string, appAccountToken: string) {
  // appAccountToken is a v4 UUID; the server uses it to bind the purchase to a user
  const result = await IAP.requestPurchase({
    sku: productId,
    appAccountToken,
  });
 
  if (result.verificationResult === 'unverified') {
    // Still send the jws to the server. Let the server decide.
    await apiPost('/billing/verify', {
      jws: result.jwsRepresentationIOS,
      productId,
      appAccountToken,
      clientVerification: 'failed',
    });
    throw new Error('Local verification failed; awaiting server decision');
  }
 
  // Optimistically update the UI
  setLocalEntitlement(productId, 'pending-server-verify');
 
  const server = await apiPost('/billing/verify', {
    jws: result.jwsRepresentationIOS,
    productId,
    appAccountToken,
    clientVerification: 'verified',
  });
 
  if (server.entitlement === 'active') {
    setLocalEntitlement(productId, 'active');
    // Only finish the transaction after the server approves
    await IAP.finishTransaction(result);
  } else {
    setLocalEntitlement(productId, 'inactive');
    // Not finishing means StoreKit re-delivers it on next launch
  }
}

The trap is where you call finishTransaction. Finish before the server says yes, and a "no" response from the server leaves StoreKit with no memory of the transaction — you lose the restore handle. The rule: finish only after the server has spoken.

Thank you for reading this far.

Continue Reading

What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.

WHAT YOU'LL LEARN
Full Node.js/TypeScript implementation of server-side receipt verification, including JWS chain validation
Idempotent retry design for App Store Server Notifications V2 that survives Apple's duplicate deliveries
Three-layer entitlement logic that keeps refund rate under 0.6% while improving restore success
Secure payment via Stripe · Cancel anytime

Unlock This Article

Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.

or
Unlock all articles with Membership →
Share

Thank You for Reading

Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • Copy-paste ready implementation code
  • New advanced guides published daily
  • $5/mo or $10 for lifetime access
View Membership →

Related Articles

Dev Tools2026-04-25
Outgrowing RevenueCat: A Self-Hosted Guide to App Store Server Notifications V2 for Rork Apps
A complete implementation guide for receiving App Store Server Notifications V2 in Cloudflare Workers without RevenueCat — covering JWS verification, idempotent state updates, user binding, and production testing for Rork apps.
Dev Tools2026-07-11
Verifying StoreKit 2 Signed Transactions in a Cloudflare Worker
If you trust whatever the device says about its purchase state, a tampered entitlement walks right through. This walks through verifying StoreKit 2 signed transactions (JWS) in a Cloudflare Worker so you grant entitlements without trusting the client, with working code.
Dev Tools2026-06-30
Answering a Refund Request Within 12 Hours: Handling CONSUMPTION_REQUEST in Cloudflare Workers
How to receive the App Store CONSUMPTION_REQUEST notification for your Rork subscription app in Cloudflare Workers and respond to the Send Consumption Information API within 12 hours — with field-by-field mapping and the operational judgment behind it.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →