Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 20 additions & 7 deletions packages/docs/public/llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
> OpenIAP: Unified in-app purchase specification for iOS & Android
> Documentation: https://openiap.dev
> Quick Reference: https://openiap.dev/llms.txt
> Generated: 2026-07-27T13:04:16.611Z
> Generated: 2026-07-27T21:51:57.841Z

## Table of Contents
1. Installation
Expand Down Expand Up @@ -40,13 +40,13 @@ pod 'openiap', '~> 2.4.4'
### Kotlin (Android)
```kotlin
// Gradle (build.gradle.kts)
implementation("io.github.hyochan.openiap:openiap-google:2.5.1")
implementation("io.github.hyochan.openiap:openiap-google:2.5.2")

// For Meta Horizon OS
implementation("io.github.hyochan.openiap:openiap-google-horizon:2.5.1")
implementation("io.github.hyochan.openiap:openiap-google-horizon:2.5.2")

// For Fire OS (Amazon Appstore)
implementation("io.github.hyochan.openiap:openiap-google-amazon:2.5.1")
implementation("io.github.hyochan.openiap:openiap-google-amazon:2.5.2")
```

### Flutter
Expand All @@ -55,13 +55,13 @@ flutter pub add flutter_inapp_purchase
```

### Godot
Download `godot-iap-2.6.1.zip` from GitHub Releases, extract it to
Download `godot-iap-2.6.2.zip` from GitHub Releases, extract it to
`addons/godot-iap/`, then enable the plugin in Project Settings.

### Kotlin Multiplatform
```kotlin
dependencies {
implementation("io.github.hyochan:kmp-iap:2.7.1")
implementation("io.github.hyochan:kmp-iap:2.7.2")
}
```

Expand All @@ -73,7 +73,7 @@ https://central.sonatype.com/artifact/io.github.hyochan/kmp-iap
dotnet add package OpenIap.Maui
```

Current NuGet package version: 1.4.1
Current NuGet package version: 1.4.2

Requires .NET 9 or .NET 10, the MAUI workload, iOS 15.0+, and Android API 24+.

Expand Down Expand Up @@ -1972,6 +1972,8 @@ Bearer header and out of URLs.
- [GET /v1/products/{apiKey}/{productId}/client-payload?platform=IOS](https://kit.openiap.dev/docs/products) — fetch `{ clientPayload }`; returns 404 when the product is missing/Removed or no payload exists
- [GET/PUT/DELETE /v1/products/client-payload/{productId}?platform=IOS](https://kit.openiap.dev/docs/products) — read durable editor state or create/update/remove a payload from CI or MCP with `Authorization: Bearer <secret-key>`; publishable keys receive 403
- [GET /v1/products and catalog/store-sync writes](https://openiap.dev/docs/kit-backend) — header-authenticated GET is client-safe; POST/DELETE and `/v1/products/sync/{ios|android}` require `Authorization: Bearer <secret-key>`; secret-key-in-path requests receive 410
- [GET /v1/subscriptions/status?userId=...](https://kit.openiap.dev/docs/api) — publishable Bearer-key entitlement gate; save the weak `ETag`, resend it as `If-None-Match`, and reuse the persisted snapshot on body-free `304`
- [GET /v1/subscriptions/entitlements?userId=...](https://kit.openiap.dev/docs/api) — publishable Bearer-key active product snapshot with the same conditional contract; supports up to 200 indexed subscription rows and fails closed on overflow
- [GET /v1/subscriptions/{list|metrics|revenue}](https://openiap.dev/docs/kit-backend) — administrative subscription data with `Authorization: Bearer <secret-key>`
- [POST /v1/webhooks/{apiKey}](https://openiap.dev/docs/webhooks#setup) — lifecycle webhook receiver. Paste this URL into App Store Connect (Production + Sandbox) AND Google Cloud Pub/Sub push subscription. Auto-detects ASN v2 vs Pub/Sub by payload shape. **POST-only**; opening in a browser returns 404 — that's expected.
- [GET /v1/openapi](https://kit.openiap.dev/v1/openapi) — machine-readable OpenAPI spec
Expand All @@ -1982,6 +1984,17 @@ Bearer header and out of URLs.
Also mounted at `/api/v1/*` for backwards compatibility. `/v1/verify-purchase`
is an alias of `/v1/purchase/verify`. Pick `/v1/purchase/verify` for new code.

Apple ASN v2 and Google RTDN update IAPKit's canonical subscription state.
IAPKit does not relay those events through SSE, WebSockets, push, or long
polling. Apps persist only the user-scoped fields they need and conditionally
refresh on cold start, stale foreground, or explicit user action. Each refresh
still performs one mutation-free indexed Convex query so an expiry with no new
webhook is detected. Respect `429 Retry-After`, coalesce concurrent refreshes,
and enforce an app-defined maximum stale age for offline fallback. Secret-key
responses are `private, no-store` and omit `ETag`. The current
`kitApi.status()` and `kitApi.entitlements()` helpers are unconditional,
body-only reads; use raw HTTP or an app wrapper to retain response headers.

## Request shapes (discriminated on `store`)

- Apple — `{ store: "apple", jws, expectedProductId?, includeClientPayload? }` where `jws` is a StoreKit 2 JWS (≤ 16 KB)
Expand Down
16 changes: 8 additions & 8 deletions packages/docs/public/llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
> OpenIAP: Unified in-app purchase specification for iOS & Android
> Documentation: https://openiap.dev
> Full Reference: https://openiap.dev/llms-full.txt
> Generated: 2026-07-27T13:04:16.611Z
> Generated: 2026-07-27T21:51:57.841Z

## Installation

Expand All @@ -24,9 +24,9 @@ npm install react-native-iap

```kotlin
// Gradle
implementation("io.github.hyochan.openiap:openiap-google:2.5.1")
implementation("io.github.hyochan.openiap:openiap-google-horizon:2.5.1")
implementation("io.github.hyochan.openiap:openiap-google-amazon:2.5.1")
implementation("io.github.hyochan.openiap:openiap-google:2.5.2")
implementation("io.github.hyochan.openiap:openiap-google-horizon:2.5.2")
implementation("io.github.hyochan.openiap:openiap-google-amazon:2.5.2")
```

```bash
Expand All @@ -36,20 +36,20 @@ flutter pub add flutter_inapp_purchase

```gdscript
# Godot
# Install godot-iap 2.6.1 to addons/godot-iap and enable the plugin
# Install godot-iap 2.6.2 to addons/godot-iap and enable the plugin
```

```kotlin
// Kotlin Multiplatform
implementation("io.github.hyochan:kmp-iap:2.7.1")
implementation("io.github.hyochan:kmp-iap:2.7.2")
```

```xml
<!-- .NET MAUI -->
<PackageReference Include="OpenIap.Maui" Version="1.4.1" />
<PackageReference Include="OpenIap.Maui" Version="1.4.2" />
```

Current NuGet package version: 1.4.1
Current NuGet package version: 1.4.2

## Framework Libraries

Expand Down
208 changes: 200 additions & 8 deletions packages/docs/src/pages/docs/kit-backend.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,14 +65,13 @@ function KitBackend() {
<code>/google</code> aliases remain supported for existing setups.
</li>
<li>
<code>GET /v1/subscriptions/status/&#123;apiKey&#125;?userId=</code>{' '}
— fast entitlement gate.
<code>GET /v1/subscriptions/status?userId=</code> — fast entitlement
gate with a publishable Bearer key. The key-in-path form remains a
compatibility alias.
</li>
<li>
<code>
GET /v1/subscriptions/entitlements/&#123;apiKey&#125;?userId=
</code>{' '}
— every active productId for a user.
<code>GET /v1/subscriptions/entitlements?userId=</code> — every
active productId for a user, also with a publishable Bearer key.
</li>
<li>
<code>GET /v1/subscriptions/list</code> — secret
Expand All @@ -83,8 +82,9 @@ function KitBackend() {
Bearer-authenticated MRR, churn, and refund counts.
</li>
<li>
<code>POST /v1/subscriptions/bind-user/&#123;apiKey&#125;</code> —
attach a userId to a tracked subscription by purchase token.
<code>POST /v1/subscriptions/bind-user</code> — attach a userId to a
tracked subscription by purchase token with a publishable Bearer
key. The key-in-path form remains a compatibility alias.
</li>
<li>
<code>GET /v1/products/&#123;publishableKey&#125;</code> — client
Expand Down Expand Up @@ -547,6 +547,198 @@ if (status.Active)
),
}}
</LanguageTabs>

<AnchorLink id="refresh-entitlements-without-sse" level="h3">
Recommended SSE replacement: conditional snapshots
</AnchorLink>
<p>
App Store Server Notifications v2 and Google RTDN update IAPKit&apos;s
canonical subscription rows. IAPKit deliberately does not relay those
events to apps, expose a raw event feed, or keep an SSE, WebSocket, or
long-poll connection open. The app reads only the latest snapshot for
its opaque <code>userId</code>.
</p>
<p>
Webhook event rows are bounded operational history for deduplication
and retention. Polling reads the canonical snapshot, not an event
cursor, so pruning old event rows does not erase the user&apos;s
latest state and apps do not have to consume every event in order.
</p>
<ol>
<li>
For a purchase started in this app session, update the UI from the
SDK purchase callback and verified result. Bind its stable purchase
token to the app-scoped user ID once.
</li>
<li>Render from a persisted status or entitlement snapshot.</li>
<li>
Revalidate on cold start, when a foreground snapshot is stale, or
after an explicit user refresh. Use one refresh coordinator so
concurrent screens share the same in-flight request; do not run a
continuous timer. Define a maximum stale age for offline or
rate-limited fallback and fail closed after that app-specific
window.
</li>
<li>
Save the response <code>ETag</code> and send it as{' '}
<code>If-None-Match</code> next time. Reuse the cached body on{' '}
<code>304</code>; replace it on <code>200</code>.
</li>
<li>
Respect <code>429 Retry-After</code> and use jittered backoff after
network failures.
</li>
<li>
Key local storage by IAPKit project and opaque user ID, and clear it
on sign-out. Never render one user&apos;s cached snapshot for
another.
</li>
</ol>
<p>
The example below uses the raw HTTP contract so it can retain response
headers. The current <code>kitApi.status()</code> and{' '}
<code>kitApi.entitlements()</code> convenience methods still perform
unconditional reads and return only the decoded body; use raw{' '}
<code>fetch</code> or an app wrapper when you need conditional
revalidation.
</p>
<CodeBlock language="typescript">{`type CachedEntitlements = {
cacheScope: string; // App-defined IAPKit project/environment identifier.
etag: string | null;
checkedAt: number;
snapshot: {
userId: string;
productIds: string[];
subscriptions: Array<{
productId: string;
state: string;
expiresAt?: number;
renewsAt?: number;
willRenew?: boolean;
}>;
};
};

async function refreshEntitlements(
iapkitCacheScope: string,
userId: string,
cached: CachedEntitlements | null,
maxStaleMs: number,
iapkitPublishableKey: string,
) {
const cachedForScope =
cached?.cacheScope === iapkitCacheScope &&
cached.snapshot.userId === userId
? cached
: null;
const canUseCachedSnapshot = () => {
if (
cachedForScope === null ||
!Number.isFinite(cachedForScope.checkedAt) ||
!Number.isFinite(maxStaleMs) ||
maxStaleMs < 0
) {
return false;
}
const cacheAgeMs = Date.now() - cachedForScope.checkedAt;
return cacheAgeMs >= 0 && cacheAgeMs <= maxStaleMs;
};
let response: Response;
try {
response = await fetch(
\`https://kit.openiap.dev/v1/subscriptions/entitlements?userId=\${encodeURIComponent(userId)}\`,
{
headers: {
Authorization: \`Bearer \${iapkitPublishableKey}\`,
...(cachedForScope?.etag
? { 'If-None-Match': cachedForScope.etag }
: {}),
},
},
);
} catch (error) {
if (cachedForScope && canUseCachedSnapshot()) {
return cachedForScope.snapshot;
}
throw error;
}

if (response.status === 304 && cachedForScope) {
const refreshed = { ...cachedForScope, checkedAt: Date.now() };
await persistEntitlements(refreshed);
return refreshed.snapshot;
}
if (response.status === 429) {
scheduleRetry(response.headers.get('Retry-After'));
if (cachedForScope && canUseCachedSnapshot()) {
return cachedForScope.snapshot;
}
throw new Error('Entitlement refresh is rate limited');
}
if (!response.ok) throw new Error('Entitlement refresh failed');

const wireSnapshot = (await response.json()) as {
userId: string;
productIds: string[];
subscriptions: Array<{
productId: string;
state: string;
expiresAt?: number;
renewsAt?: number;
willRenew?: boolean;
}>;
};
const snapshot = {
userId: wireSnapshot.userId,
productIds: wireSnapshot.productIds,
subscriptions: wireSnapshot.subscriptions.map(
({ productId, state, expiresAt, renewsAt, willRenew }) => ({
productId,
state,
expiresAt,
renewsAt,
willRenew,
}),
),
};
await persistEntitlements({
cacheScope: iapkitCacheScope,
snapshot,
etag: response.headers.get('ETag'),
checkedAt: Date.now(),
});
return snapshot;
}`}</CodeBlock>
<div className="alert-card alert-card--info">
<p>
<strong>A 304 still makes one Convex query invocation.</strong>{' '}
Convex returns a time-independent row snapshot and invalidates its
cached result when a dependent row changes. Fly then evaluates
expiry against its own current clock on every HTTP request. Cached
query results and caller-controlled timestamps therefore cannot
preserve expired access, and a time-only transition is detected on
the next refresh. The conditional request avoids response-body
transfer and unnecessary app state replacement; it does not make
aggressive polling free.
</p>
<p>
A user snapshot supports up to 200 subscription rows. IAPKit reads
one additional indexed row only to detect overflow and returns{' '}
<code>400 ENTITLEMENT_SNAPSHOT_TOO_LARGE</code> instead of exposing
a partial entitlement set.
</p>
<p>
Mobile snapshots are suitable for UI and local feature gating. If a
developer-owned backend protects paid content, that backend must
authenticate the user and make the entitlement decision. It also
owns any optional APNs or FCM delivery.
</p>
<p>
Persist only the fields the UI needs. In particular, avoid retaining
purchase tokens in general-purpose local storage when product IDs,
states, and expiry times are sufficient.
</p>
</div>
</section>

<section>
Expand Down
Loading