Skip to content

feat(gql): add webhook event spec for ASN v2 / RTDN normalization#123

Closed
hyochan wants to merge 1 commit into
mainfrom
feat/webhook-spec
Closed

feat(gql): add webhook event spec for ASN v2 / RTDN normalization#123
hyochan wants to merge 1 commit into
mainfrom
feat/webhook-spec

Conversation

@hyochan

@hyochan hyochan commented Apr 30, 2026

Copy link
Copy Markdown
Member

Summary

  • Adds packages/gql/src/webhook.graphql defining a unified WebhookEvent shape that normalizes Apple ASN v2 and Google RTDN into a single cross-store event surface, streamed to clients via a Subscription.webhookEvent field with a Query.webhookEventsSince backfill.
  • Documents the ASN v2 ↔ RTDN ↔ openiap mapping in knowledge/external/webhook-mapping.md as the SSOT for the kit receiver implementation that lands in PR Doc mismatch with expo-iap implementation and some confusions  #2.
  • Regenerates types for all 5 target languages and syncs them to all 7 downstream SDK type files.

Why

This is Phase 1 / PR #1 of the roadmap that turns kit into a full backend (lifecycle webhooks → subscription state → revenue metrics → product CRUD → paywall → MCP). The end goal is for openiap users to ship subscription apps without operating their own server: kit handles store-side notifications and pushes normalized events directly to clients.

This PR ships only the spec + mapping doc. The next PR adds the kit receivers (JWS / OIDC verification, idempotency, normalization) and unit tests against fixture payloads.

Event coverage

  • Subscription lifecycle (13): Started, Renewed, Expired, InGracePeriod, InBillingRetry, Recovered, Canceled, Uncanceled, Revoked, PriceChange, ProductChanged, Paused, Resumed
  • One-time / common (3): PurchaseRefunded, PurchaseConsumptionRequest, TestNotification

Test plan

  • bun run generate — codegen passes for swift / kotlin / dart / gdscript / typescript
  • Sync to all 7 SDK type files succeeded (bun run sync)
  • bun run audit:docs — no new failures (7 pre-existing warnings unrelated to this PR)
  • Schema lint passes (0 errors)
  • Reviewer to sanity-check naming against knowledge/internal/01-naming-conventions.md
  • Reviewer to sanity-check the mapping table against the latest ASN v2 / RTDN docs

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added webhook event support across all SDKs with ability to stream real-time purchase notifications and retrieve missed events from the past 30 days.
  • Documentation

    • Added specification for webhook event normalization across Apple and Google notification systems.

Introduce `webhook.graphql` defining the normalized cross-store lifecycle
event surface that kit will emit to clients. This is the foundation for
removing the need for users to run their own server: kit ingests Apple
ASN v2 and Google RTDN, normalizes them into one shape, and streams them
to authenticated clients via a GraphQL Subscription transport.

- 15 unified WebhookEventType values covering subscription lifecycle
  (started/renewed/expired/grace/retry/recovered/canceled/uncanceled/
  revoked/price-change/product-changed/paused/resumed) plus refunds,
  consumption requests, and test notifications.
- WebhookEventSource discriminator (ASN v2 vs RTDN) and Environment
  (Production/Sandbox/Xcode).
- WebhookEvent payload with idempotency `id`, occurredAt/receivedAt
  epoch-ms timestamps, cross-platform `purchaseToken`, optional
  subscription state and price snapshot, plus `rawSignedPayload` escape
  hatch.
- `Subscription.webhookEvent` for live streaming and
  `Query.webhookEventsSince` for reconnection backfill.
- ASN v2 ↔ RTDN ↔ openiap mapping table in
  `knowledge/external/webhook-mapping.md` (SSOT for the kit receivers
  shipping in the next PR).

Codegen verified across all 5 target languages; types synced to all 7
SDK files (apple, google, rn-iap, expo-iap, flutter, godot, kmp).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Apr 30, 2026

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

This pull request introduces a comprehensive webhook event normalization system across the OpenIAP platform, adding a new GraphQL schema that unifies Apple ASN v2 and Google RTDN lifecycle notifications into a common WebhookEvent model, with corresponding type definitions added to 6 SDK libraries and 2 platform packages.

Changes

Cohort / File(s) Summary
Documentation & Specification
knowledge/external/webhook-mapping.md
Defines webhook event normalization spec, including ASN/RTDN mapping rules, receiver validation (JWS signatures, OIDC tokens), idempotency deduplication via Convex, and 30-day retention policy with cron-based pruning.
GraphQL Schema & Codegen
packages/gql/src/webhook.graphql, packages/gql/codegen.ts, packages/gql/codegen/core/parser.ts
Adds webhook GraphQL schema defining WebhookEvent type, source/environment/type enums, subscription state, and query/subscription operations; updates codegen config and schema parser to include webhook definitions in generated types.
SDK Type Definitions
libraries/expo-iap/src/types.ts, libraries/react-native-iap/src/types.ts
Adds WebhookEvent, SubscriptionState, and related enums to TypeScript SDKs; extends Query with webhookEventsSince and Subscription with webhookEvent.
SDK Type Definitions (Dart)
libraries/flutter_inapp_purchase/lib/types.dart
Introduces webhook enums, WebhookEvent class with JSON serialization, and resolver interfaces for webhookEventsSince query and webhookEvent subscription in Flutter SDK.
SDK Type Definitions (GDScript)
libraries/godot-iap/addons/godot-iap/types.gd
Adds webhook event model and enums with from_dict/to_dict serialization; extends API with webhookEventsSince query in Godot SDK.
SDK Type Definitions (Kotlin)
libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt
Adds webhook enums and WebhookEvent data class with JSON conversion; updates QueryResolver and SubscriptionResolver interfaces with webhook operations in KMP SDK.
Package Implementations
packages/apple/Sources/Models/Types.swift, packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt
Implements normalized webhook model with enums and WebhookEvent struct/class; extends resolver protocols/interfaces and handler configuration for Apple (Swift) and Google (Kotlin) packages.

Sequence Diagram(s)

sequenceDiagram
    participant Client as Client
    participant Server as Server
    participant Validator as Signature/Token<br/>Validator
    participant Deduplicator as Idempotency<br/>Deduplicator
    participant Storage as Event Storage<br/>(Convex)

    Note over Client,Storage: Webhook Event Reception & Validation Flow
    
    rect rgba(100, 150, 255, 0.5)
        Client->>Server: Push webhook event<br/>(Apple JWS or Google Pub/Sub)
        Server->>Validator: Verify signature/token<br/>(JWS or OIDC)
        alt Signature/Token Invalid
            Validator-->>Client: HTTP 401 Unauthorized
        else Signature/Token Valid
            Validator-->>Server: ✓ Verified
        end
    end
    
    rect rgba(150, 200, 100, 0.5)
        Server->>Deduplicator: Check idempotency<br/>(source, sourceNotificationId)
        alt Event Already Processed
            Deduplicator-->>Server: Duplicate detected
            Server-->>Client: HTTP 200 OK<br/>(silent acknowledgement)
        else New Event
            Deduplicator-->>Server: New event
        end
    end
    
    rect rgba(255, 150, 100, 0.5)
        Server->>Storage: Store normalized<br/>WebhookEvent
        Storage-->>Server: Event stored
        Server-->>Client: HTTP 200 OK
    end
    
    Note over Storage: Cron Job (30-day retention)
    rect rgba(200, 150, 255, 0.5)
        Storage->>Storage: Prune events<br/>older than 30 days
    end
Loading

Estimated Code Review Effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly Related PRs

Suggested Labels

🎯 feature, ⬡ gql, cross-platform

Poem

🐰 Hops of joy for webhooks bright,
Events stream through day and night,
Seven platforms sing in tune,
Normalized by this blessed June!
Apple, Google—now as one,
Webhook magic has begun! 🎉

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 23.53% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat(gql): add webhook event spec for ASN v2 / RTDN normalization' accurately and concisely describes the main change—adding a GraphQL webhook spec for normalizing Apple ASN v2 and Google RTDN events.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/webhook-spec

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
Review rate limit: 0/1 reviews remaining, refill in 60 minutes.

Comment @coderabbitai help to get the list of available commands and usage tips.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a unified server-side webhook event system by defining a normalized WebhookEvent schema in GraphQL and generating corresponding types across all supported platform libraries. It also adds documentation mapping Apple ASN v2 and Google RTDN fields to the unified format. Feedback highlights the need for stricter validation of required fields during deserialization in the Godot and Kotlin generated code to prevent silent failures. Additionally, a terminology correction was suggested in the documentation to align with Apple's official 'milli-units' specification.

Comment on lines +3339 to +3399
static func from_dict(data: Dictionary) -> WebhookEvent:
var obj = WebhookEvent.new()
if data.has("id") and data["id"] != null:
obj.id = data["id"]
if data.has("type") and data["type"] != null:
var enum_str = data["type"]
if enum_str is String and WEBHOOK_EVENT_TYPE_FROM_STRING.has(enum_str):
obj.type = WEBHOOK_EVENT_TYPE_FROM_STRING[enum_str]
else:
obj.type = enum_str
if data.has("source") and data["source"] != null:
var enum_str = data["source"]
if enum_str is String and WEBHOOK_EVENT_SOURCE_FROM_STRING.has(enum_str):
obj.source = WEBHOOK_EVENT_SOURCE_FROM_STRING[enum_str]
else:
obj.source = enum_str
if data.has("platform") and data["platform"] != null:
var enum_str = data["platform"]
if enum_str is String and IAP_PLATFORM_FROM_STRING.has(enum_str):
obj.platform = IAP_PLATFORM_FROM_STRING[enum_str]
else:
obj.platform = enum_str
if data.has("projectId") and data["projectId"] != null:
obj.project_id = data["projectId"]
if data.has("occurredAt") and data["occurredAt"] != null:
obj.occurred_at = data["occurredAt"]
if data.has("receivedAt") and data["receivedAt"] != null:
obj.received_at = data["receivedAt"]
if data.has("environment") and data["environment"] != null:
var enum_str = data["environment"]
if enum_str is String and WEBHOOK_EVENT_ENVIRONMENT_FROM_STRING.has(enum_str):
obj.environment = WEBHOOK_EVENT_ENVIRONMENT_FROM_STRING[enum_str]
else:
obj.environment = enum_str
if data.has("purchaseToken") and data["purchaseToken"] != null:
obj.purchase_token = data["purchaseToken"]
if data.has("productId") and data["productId"] != null:
obj.product_id = data["productId"]
if data.has("subscriptionState") and data["subscriptionState"] != null:
var enum_str = data["subscriptionState"]
if enum_str is String and SUBSCRIPTION_STATE_FROM_STRING.has(enum_str):
obj.subscription_state = SUBSCRIPTION_STATE_FROM_STRING[enum_str]
else:
obj.subscription_state = enum_str
if data.has("expiresAt") and data["expiresAt"] != null:
obj.expires_at = data["expiresAt"]
if data.has("renewsAt") and data["renewsAt"] != null:
obj.renews_at = data["renewsAt"]
if data.has("cancellationReason") and data["cancellationReason"] != null:
var enum_str = data["cancellationReason"]
if enum_str is String and WEBHOOK_CANCELLATION_REASON_FROM_STRING.has(enum_str):
obj.cancellation_reason = WEBHOOK_CANCELLATION_REASON_FROM_STRING[enum_str]
else:
obj.cancellation_reason = enum_str
if data.has("currency") and data["currency"] != null:
obj.currency = data["currency"]
if data.has("priceAmountMicros") and data["priceAmountMicros"] != null:
obj.price_amount_micros = data["priceAmountMicros"]
if data.has("rawSignedPayload") and data["rawSignedPayload"] != null:
obj.raw_signed_payload = data["rawSignedPayload"]
return obj

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

The from_dict function is too lenient for required fields. If a required field from the GraphQL schema (like id, type, platform, etc.) is missing from the input data dictionary, the corresponding property will retain its default value (e.g., "" for id). This can hide server-side issues or contract violations and lead to hard-to-debug runtime errors on the client.

The parsing logic for required fields should be stricter. It should raise an error if a required field is missing or null.

For example, for the id field, you could do something like this:

if data.has("id") and data["id"] != null:
    obj.id = data["id"]
else:
    push_error("WebhookEvent.from_dict: missing required field 'id'")
    # Or handle the error in another idiomatic way for Godot.

This issue affects all generated GDScript files with this from_dict implementation. Please update the GDScript codegen plugin to enforce the presence of required fields during deserialization.

Comment on lines +3902 to +3922
fun fromJson(json: Map<String, Any?>): WebhookEvent {
return WebhookEvent(
cancellationReason = (json["cancellationReason"] as? String)?.let { WebhookCancellationReason.fromJson(it) },
currency = json["currency"] as? String,
environment = (json["environment"] as? String)?.let { WebhookEventEnvironment.fromJson(it) } ?: WebhookEventEnvironment.Production,
expiresAt = (json["expiresAt"] as? Number)?.toDouble(),
id = json["id"] as? String ?: "",
occurredAt = (json["occurredAt"] as? Number)?.toDouble() ?: 0.0,
platform = (json["platform"] as? String)?.let { IapPlatform.fromJson(it) } ?: IapPlatform.Ios,
priceAmountMicros = (json["priceAmountMicros"] as? Number)?.toDouble(),
productId = json["productId"] as? String,
projectId = json["projectId"] as? String ?: "",
purchaseToken = json["purchaseToken"] as? String ?: "",
rawSignedPayload = json["rawSignedPayload"] as? String,
receivedAt = (json["receivedAt"] as? Number)?.toDouble() ?: 0.0,
renewsAt = (json["renewsAt"] as? Number)?.toDouble(),
source = (json["source"] as? String)?.let { WebhookEventSource.fromJson(it) } ?: WebhookEventSource.AppleAppStoreServerNotificationsV2,
subscriptionState = (json["subscriptionState"] as? String)?.let { SubscriptionState.fromJson(it) },
type = (json["type"] as? String)?.let { WebhookEventType.fromJson(it) } ?: WebhookEventType.SubscriptionStarted,
)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

The fromJson method is too lenient for required fields defined in the GraphQL schema. It provides default values for non-nullable fields like id, occurredAt, platform, and type if they are missing from the JSON payload. This can mask contract violations between the client and server and lead to silent failures with incorrect data.

For example, if the platform field is missing, it defaults to IapPlatform.Ios, which would be incorrect for an event from Google Play.

The parsing logic should be stricter and throw an exception if a required field is missing or null. For instance, id = json["id"] as String would be safer than id = json["id"] as? String ?: "".

This issue affects all generated Kotlin files with this fromJson implementation (e.g., in kmp-iap and packages/gql/src/generated). Please update the Kotlin codegen plugin to enforce non-nullability for required fields during deserialization.

| `renewsAt` | `data.signedRenewalInfo.renewalDate` | resolved by calling `purchases.subscriptionsv2.get` |
| `cancellationReason` | `data.signedTransactionInfo.revocationReason` + ASN `subtype` | `purchases.subscriptionsv2.get` → `canceledStateContext.userInitiatedCancellation` / `systemInitiatedCancellation` |
| `currency` | `data.signedTransactionInfo.currency` | from `purchases.subscriptionsv2.get` linked product price |
| `priceAmountMicros` | `data.signedTransactionInfo.price` × 1000 (ASN reports in millicents; convert to micros) | `purchases.subscriptionsv2.get` → `lineItems[*].autoRenewingPlan.recurringPrice.units` |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The term "millicents" is ambiguous and potentially misleading. Apple's documentation for signedTransactionInfo specifies that the price is in "milli-units" of the currency. A milli-unit is 1/1000 of a currency unit (e.g., $0.001), whereas a millicent would be 1/1000 of a cent (e.g., $0.00001). To improve clarity and prevent implementation errors, please use the official term "milli-units".

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 12

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@knowledge/external/webhook-mapping.md`:
- Line 55: The mapping for priceAmountMicros currently uses only
recurringPrice.units and ignores recurringPrice.nanos, causing fractional prices
to be understated; update the logic that computes priceAmountMicros (from
data.signedTransactionInfo.price / purchases.subscriptionsv2.get →
lineItems[*].autoRenewingPlan.recurringPrice) to parse units as an integer
(units is a string) and include nanos by computing totalMicros = (units *
1_000_000) + (nanos / 1_000), taking care that nanos may be negative and
performing integer-safe division/truncation as appropriate.
- Around line 15-20: The lifecycle table contains outdated/mismatched RTDN
numeric codes: review the current Google Play RTDN docs and update the numeric
codes for SUBSCRIPTION_PURCHASED and SUBSCRIPTION_RECOVERED (they are currently
swapped) and replace the “no fixed code” note for SubscriptionProductChanged
with the official RTDN code/name for subscription item changes; update the rows
referencing SubscriptionStarted, SubscriptionRenewed, SubscriptionExpired,
SubscriptionInGracePeriod, SubscriptionInBillingRetry, and SubscriptionRecovered
so the numeric codes and footnote(s) match the authoritative RTDN reference.

In `@libraries/expo-iap/src/types.ts`:
- Around line 1409-1414: The Query interface's webhookEventsSince property is
typed as WebhookEvent[] but must be asynchronous like other Query fields; change
the type of webhookEventsSince on the Query interface to Promise<WebhookEvent[]>
so that QueryField<'webhookEventsSince'> is correctly typed as async. Update the
declaration for webhookEventsSince (symbol: Query.webhookEventsSince) to return
Promise<WebhookEvent[]> and run type checks to ensure callers await the result
where needed.

In `@libraries/flutter_inapp_purchase/lib/types.dart`:
- Around line 1098-1100: The enum/RTDN mapping incorrectly treats
SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED as a resume; update the handling so that
SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED is NOT mapped to SubscriptionResumed (remove
it from any switch/case or mapping that returns SubscriptionResumed) and instead
only map SUBSCRIPTION_RECOVERED and/or SUBSCRIPTION_RENEWED to
SubscriptionResumed; if you need to represent the pause-schedule change create a
distinct symbol (e.g., SubscriptionPauseScheduleChanged) and map
SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED to that instead.
- Around line 1071-1082: The Android RTDN mapping is wrong: remove
SUBSCRIPTION_RESTARTED from the comment/Android list under the
SubscriptionRecovered enum entry (SubscriptionRecovered) and instead list
SUBSCRIPTION_RESTARTED under SubscriptionUncanceled; ensure
SubscriptionRecovered only references SUBSCRIPTION_RECOVERED and
SubscriptionUncanceled documents SUBSCRIPTION_RESTARTED as the event for
re-enabling before expiry so there is no double-mapping between the two enum
entries.

In `@libraries/godot-iap/addons/godot-iap/types.gd`:
- Around line 5442-5457: The generated Args class for webhookEventsSince
incorrectly makes limit required; update the generated code (or regenerate
types.gd via scripts/generate-types.sh) so that the property limit is optional
and omitted from serialization: change the field declaration for limit in the
Args class (the variable named limit), ensure from_dict still only sets
obj.limit when data.has("limit") and data["limit"] != null, and modify to_dict()
(method to_dict on Args) to only include dict["limit"] when limit is not
null/undefined so unset behavior is preserved.
- Around line 3324-3331: WebhookEvent's nullable enum fields subscription_state
and cancellation_reason are being generated as non-nullable enum types causing
default enum value 0 to appear instead of null; update the code generator that
emits libraries/godot-iap/addons/godot-iap/types.gd so that nullable enum schema
fields are emitted with a null initializer (e.g., "var subscription_state:
SubscriptionState = null" and "var cancellation_reason:
WebhookCancellationReason = null") or are typed as Variant when appropriate, and
ensure the generator's serialization/deserialization logic preserves and emits
null for these fields rather than converting missing values to the enum zero
value.

In
`@libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt`:
- Around line 4026-4040: The WebhookEvent JSON parser is currently silently
defaulting required fields (id, occurredAt, projectId, purchaseToken,
receivedAt, environment, source, type, platform) which can create synthetic
valid events; update the WebhookEvent.fromJson logic in Types.kt to validate
these required fields instead of providing defaults: for each required field
(refer to id, occurredAt, projectId, purchaseToken, receivedAt, environment
using WebhookEventEnvironment.fromJson, source using
WebhookEventSource.fromJson, type using WebhookEventType.fromJson, and platform
using IapPlatform.fromJson) return an error/null or throw an exception when
missing or unparsable, and only allow nullable/defaulting behavior for truly
optional fields like priceAmountMicros, expiresAt, renewsAt, rawSignedPayload,
subscriptionState, thereby preventing malformed payloads from being treated as
valid events.

In `@libraries/react-native-iap/src/types.ts`:
- Around line 1409-1414: The Query type's webhookEventsSince field is currently
declared as synchronous (WebhookEvent[]) but should return a Promise to match
the Query API surface; update the declaration of webhookEventsSince in types.ts
to return Promise<WebhookEvent[]> (i.e., change the type from WebhookEvent[] to
Promise<WebhookEvent[]]) and ensure any related generated query field helpers or
consumers of Query.webhookEventsSince are updated to await the result
accordingly so the types remain consistent across the codebase.

In `@packages/apple/Sources/Models/Types.swift`:
- Around line 1419-1444: WebhookEvent.purchaseToken is currently non-optional
but RTDN testNotification events lack a purchase token; change the GraphQL
schema field from purchaseToken: String to purchaseToken: String? and update the
generated Swift model (WebhookEvent.purchaseToken) to be an optional String,
then regenerate the schema-derived types so all usages accept nil; also update
the subscription delivery docs text that discusses token-based ownership
matching (around the existing paragraph referencing purchase-token matching) to
state that token-based matching applies only to purchase-linked events and not
to account-level test notifications.

In `@packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt`:
- Around line 3902-3921: WebhookEvent.fromJson currently substitutes required
fields with defaults; change the generator/template so WebhookEvent.fromJson
validates and fails fast by throwing when required fields are missing or invalid
(instead of using ""/0.0/default enums). In particular enforce
non-null/non-empty checks for id, occurredAt, purchaseToken, type
(WebhookEventType), projectId/purchaseToken if required by schema, and any other
required properties (e.g., receivedAt if required) inside the generated
WebhookEvent.fromJson function; replace constructs like 'as? String ?: ""' and
'as? Number ?: 0.0' and default enum fallbacks with explicit validation that
throws a descriptive exception (including the field name and raw value) so
invalid payloads are rejected at parse time rather than normalized silently.
Ensure generator emits these validation rules for WebhookEvent and similar
required models (refer to WebhookEvent.fromJson, WebhookEventType.fromJson,
WebhookEventEnvironment.fromJson, SubscriptionState.fromJson,
IapPlatform.fromJson).

In `@packages/gql/src/webhook.graphql`:
- Around line 80-83: The enum docstring that currently reads "User upgraded,
downgraded, or crossgraded their plan. iOS: DID_CHANGE_RENEWAL_PREF. Android:
SUBSCRIPTION_DEFERRED / SUBSCRIPTION_PRODUCT_CHANGED." contains outdated Android
RTDN label names; update the comment in webhook.graphql for that enum (the doc
block containing "User upgraded, downgraded, or crossgraded their plan" and the
nearby doc block referenced around the same section) to use the current Google
RTDN notification names for product-change and one-time refund/cancel
notifications by consulting Google's RTDN docs and replacing the incorrect
labels with the official RTDN event names so generated SDK docs are accurate.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0c129876-5aee-467a-9534-e473be238cf1

📥 Commits

Reviewing files that changed from the base of the PR and between c809d92 and d0fce82.

⛔ Files ignored due to path filters (5)
  • packages/gql/src/generated/Types.kt is excluded by !**/generated/**
  • packages/gql/src/generated/Types.swift is excluded by !**/generated/**
  • packages/gql/src/generated/types.dart is excluded by !**/generated/**
  • packages/gql/src/generated/types.gd is excluded by !**/generated/**
  • packages/gql/src/generated/types.ts is excluded by !**/generated/**
📒 Files selected for processing (11)
  • knowledge/external/webhook-mapping.md
  • libraries/expo-iap/src/types.ts
  • libraries/flutter_inapp_purchase/lib/types.dart
  • libraries/godot-iap/addons/godot-iap/types.gd
  • libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt
  • libraries/react-native-iap/src/types.ts
  • packages/apple/Sources/Models/Types.swift
  • packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt
  • packages/gql/codegen.ts
  • packages/gql/codegen/core/parser.ts
  • packages/gql/src/webhook.graphql

Comment on lines +15 to +20
| `SubscriptionStarted` | `SUBSCRIBED` (`INITIAL_BUY`, `RESUBSCRIBE`) | `SUBSCRIPTION_PURCHASED` (1), `SUBSCRIPTION_RECOVERED` (4)¹ |
| `SubscriptionRenewed` | `DID_RENEW` | `SUBSCRIPTION_RENEWED` (2) |
| `SubscriptionExpired` | `EXPIRED` | `SUBSCRIPTION_EXPIRED` (13) |
| `SubscriptionInGracePeriod` | `DID_FAIL_TO_RENEW` (`GRACE_PERIOD`) | `SUBSCRIPTION_IN_GRACE_PERIOD` (6) |
| `SubscriptionInBillingRetry` | `DID_FAIL_TO_RENEW` (no subtype) | `SUBSCRIPTION_ON_HOLD` (5) |
| `SubscriptionRecovered` | `DID_RENEW` (after a prior failure) | `SUBSCRIPTION_RECOVERED` (4)¹, `SUBSCRIPTION_RESTARTED` (7) |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

RTDN notification codes are incorrect/stale in the lifecycle table.

SUBSCRIPTION_PURCHASED and SUBSCRIPTION_RECOVERED numeric codes are swapped in the table/footnote, and SubscriptionProductChanged notes “no fixed code” even though RTDN documents a fixed code for item changes. Since this file is SSOT for the receiver PR, this can misroute normalization logic.

In the current Google Play RTDN reference, what numeric codes are assigned to SUBSCRIPTION_RECOVERED and SUBSCRIPTION_PURCHASED, and what is the code/name for subscription item changes?

Also applies to: 25-31

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@knowledge/external/webhook-mapping.md` around lines 15 - 20, The lifecycle
table contains outdated/mismatched RTDN numeric codes: review the current Google
Play RTDN docs and update the numeric codes for SUBSCRIPTION_PURCHASED and
SUBSCRIPTION_RECOVERED (they are currently swapped) and replace the “no fixed
code” note for SubscriptionProductChanged with the official RTDN code/name for
subscription item changes; update the rows referencing SubscriptionStarted,
SubscriptionRenewed, SubscriptionExpired, SubscriptionInGracePeriod,
SubscriptionInBillingRetry, and SubscriptionRecovered so the numeric codes and
footnote(s) match the authoritative RTDN reference.

| `renewsAt` | `data.signedRenewalInfo.renewalDate` | resolved by calling `purchases.subscriptionsv2.get` |
| `cancellationReason` | `data.signedTransactionInfo.revocationReason` + ASN `subtype` | `purchases.subscriptionsv2.get` → `canceledStateContext.userInitiatedCancellation` / `systemInitiatedCancellation` |
| `currency` | `data.signedTransactionInfo.currency` | from `purchases.subscriptionsv2.get` linked product price |
| `priceAmountMicros` | `data.signedTransactionInfo.price` × 1000 (ASN reports in millicents; convert to micros) | `purchases.subscriptionsv2.get` → `lineItems[*].autoRenewingPlan.recurringPrice.units` |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

In purchases.subscriptionsv2.get, how is recurringPrice represented, and how should units+nanos be converted to micros?

💡 Result:

In purchases.subscriptionsv2.get, recurringPrice is a Money object with fields: units (whole currency units, as a string) and nanos (the fractional part in nano = 10^-9 currency units) plus currencyCode. Example shape from the API: recurringPrice: { "units": "12", "nanos": 990000000, "currencyCode": "USD" }. To convert units+nanos to micros (1,000,000 micro-units per 1 currency unit): - totalMicros = (units * 1_000_000) + (nanos / 1000) - because nanos is in 10^-9 units, nanos / 1000 gives nano-units expressed in 10^-6 (micro) units. Practical notes: - Ensure you parse units as a number (it is a string in the Money JSON). - Use integer math; nanos may be negative (Money spec allows negative nanos when units is negative or zero).

Citations:


Fix priceAmountMicros conversion to account for nanos fractional precision.

The mapping currently uses only recurringPrice.units, ignoring the nanos field. This causes fractional prices to be understated. Correct the mapping to: totalMicros = (units × 1,000,000) + (nanos ÷ 1,000), since nanos represents 10⁻⁹ units and dividing by 1,000 converts to 10⁻⁶ (micro) units. Note: units is a string requiring parsing; nanos may be negative.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@knowledge/external/webhook-mapping.md` at line 55, The mapping for
priceAmountMicros currently uses only recurringPrice.units and ignores
recurringPrice.nanos, causing fractional prices to be understated; update the
logic that computes priceAmountMicros (from data.signedTransactionInfo.price /
purchases.subscriptionsv2.get → lineItems[*].autoRenewingPlan.recurringPrice) to
parse units as an integer (units is a string) and include nanos by computing
totalMicros = (units * 1_000_000) + (nanos / 1_000), taking care that nanos may
be negative and performing integer-safe division/truncation as appropriate.

Comment on lines +1409 to +1414
/**
* Replay missed webhook events for the authenticated client since the given
* timestamp. SDKs call this on reconnect / foreground entry to backfill events
* that occurred while the WebSocket was closed.
*/
webhookEventsSince: WebhookEvent[];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Query.webhookEventsSince should return a Promise

Line 1414 diverges from the rest of the Query interface contract. It should be Promise<WebhookEvent[]>, otherwise QueryField<'webhookEventsSince'> becomes sync-typed incorrectly.

Suggested fix
-  webhookEventsSince: WebhookEvent[];
+  webhookEventsSince: Promise<WebhookEvent[]>;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@libraries/expo-iap/src/types.ts` around lines 1409 - 1414, The Query
interface's webhookEventsSince property is typed as WebhookEvent[] but must be
asynchronous like other Query fields; change the type of webhookEventsSince on
the Query interface to Promise<WebhookEvent[]> so that
QueryField<'webhookEventsSince'> is correctly typed as async. Update the
declaration for webhookEventsSince (symbol: Query.webhookEventsSince) to return
Promise<WebhookEvent[]> and run type checks to ensure callers await the result
where needed.

Comment on lines +1071 to +1082
/// Subscription returned to active state after a billing issue or pause.
/// iOS: DID_RECOVER.
/// Android: SUBSCRIPTION_RECOVERED / SUBSCRIPTION_RESTARTED.
SubscriptionRecovered('subscription-recovered'),
/// User turned off auto-renew. Access continues until the current period ends.
/// iOS: DID_CHANGE_RENEWAL_STATUS (autoRenew turned off).
/// Android: SUBSCRIPTION_CANCELED.
SubscriptionCanceled('subscription-canceled'),
/// User reactivated auto-renew before the subscription expired.
/// iOS: DID_CHANGE_RENEWAL_STATUS (autoRenew turned on).
/// Android: SUBSCRIPTION_RESTARTED (when re-enabled, not after billing recovery).
SubscriptionUncanceled('subscription-uncanceled'),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't classify SUBSCRIPTION_RESTARTED as recovery.

Line 1073 currently lists SUBSCRIPTION_RESTARTED under SubscriptionRecovered, but Google defines that RTDN as restoring a subscription that was canceled but not yet expired. That matches the SubscriptionUncanceled semantics on Line 1079, not billing recovery, and leaves the spec with an ambiguous double-mapping for the same Play event. (developer.android.com)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@libraries/flutter_inapp_purchase/lib/types.dart` around lines 1071 - 1082,
The Android RTDN mapping is wrong: remove SUBSCRIPTION_RESTARTED from the
comment/Android list under the SubscriptionRecovered enum entry
(SubscriptionRecovered) and instead list SUBSCRIPTION_RESTARTED under
SubscriptionUncanceled; ensure SubscriptionRecovered only references
SUBSCRIPTION_RECOVERED and SubscriptionUncanceled documents
SUBSCRIPTION_RESTARTED as the event for re-enabling before expiry so there is no
double-mapping between the two enum entries.

Comment on lines +1098 to +1100
/// Paused subscription resumed (Android only feature).
/// Android: SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED / SUBSCRIPTION_RECOVERED from pause.
SubscriptionResumed('subscription-resumed'),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED is not a resume signal.

Line 1099 maps SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED to SubscriptionResumed, but Google says that RTDN is sent when the user initiates or changes a pause schedule while the subscription is still active. The actual end-of-pause success path comes back as SUBSCRIPTION_RENEWED, so this mapping would mislabel a scheduled pause as an entitlement restore. (developer.android.com)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@libraries/flutter_inapp_purchase/lib/types.dart` around lines 1098 - 1100,
The enum/RTDN mapping incorrectly treats SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED as
a resume; update the handling so that SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED is NOT
mapped to SubscriptionResumed (remove it from any switch/case or mapping that
returns SubscriptionResumed) and instead only map SUBSCRIPTION_RECOVERED and/or
SUBSCRIPTION_RENEWED to SubscriptionResumed; if you need to represent the
pause-schedule change create a distinct symbol (e.g.,
SubscriptionPauseScheduleChanged) and map SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED to
that instead.

Comment on lines +4026 to +4040
environment = (json["environment"] as? String)?.let { WebhookEventEnvironment.fromJson(it) } ?: WebhookEventEnvironment.Production,
expiresAt = (json["expiresAt"] as? Number)?.toDouble(),
id = json["id"] as? String ?: "",
occurredAt = (json["occurredAt"] as? Number)?.toDouble() ?: 0.0,
platform = (json["platform"] as? String)?.let { IapPlatform.fromJson(it) } ?: IapPlatform.Ios,
priceAmountMicros = (json["priceAmountMicros"] as? Number)?.toDouble(),
productId = json["productId"] as? String,
projectId = json["projectId"] as? String ?: "",
purchaseToken = json["purchaseToken"] as? String ?: "",
rawSignedPayload = json["rawSignedPayload"] as? String,
receivedAt = (json["receivedAt"] as? Number)?.toDouble() ?: 0.0,
renewsAt = (json["renewsAt"] as? Number)?.toDouble(),
source = (json["source"] as? String)?.let { WebhookEventSource.fromJson(it) } ?: WebhookEventSource.AppleAppStoreServerNotificationsV2,
subscriptionState = (json["subscriptionState"] as? String)?.let { SubscriptionState.fromJson(it) },
type = (json["type"] as? String)?.let { WebhookEventType.fromJson(it) } ?: WebhookEventType.SubscriptionStarted,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Do not silently default required WebhookEvent fields in fromJson.

Defaulting required fields (id, occurredAt, projectId, purchaseToken, receivedAt, environment, source, type, platform) can turn malformed payloads into valid-looking synthetic events, which risks idempotency/reconciliation errors.

Suggested fix (apply in codegen template/source, not by hand in generated output)
- environment = (json["environment"] as? String)?.let { WebhookEventEnvironment.fromJson(it) } ?: WebhookEventEnvironment.Production,
+ environment = (json["environment"] as? String)
+     ?.let { WebhookEventEnvironment.fromJson(it) }
+     ?: throw IllegalArgumentException("Missing required field: environment"),

- id = json["id"] as? String ?: "",
+ id = json["id"] as? String
+     ?: throw IllegalArgumentException("Missing required field: id"),

- occurredAt = (json["occurredAt"] as? Number)?.toDouble() ?: 0.0,
+ occurredAt = (json["occurredAt"] as? Number)?.toDouble()
+     ?: throw IllegalArgumentException("Missing required field: occurredAt"),

- projectId = json["projectId"] as? String ?: "",
+ projectId = json["projectId"] as? String
+     ?: throw IllegalArgumentException("Missing required field: projectId"),

- purchaseToken = json["purchaseToken"] as? String ?: "",
+ purchaseToken = json["purchaseToken"] as? String
+     ?: throw IllegalArgumentException("Missing required field: purchaseToken"),

- receivedAt = (json["receivedAt"] as? Number)?.toDouble() ?: 0.0,
+ receivedAt = (json["receivedAt"] as? Number)?.toDouble()
+     ?: throw IllegalArgumentException("Missing required field: receivedAt"),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt`
around lines 4026 - 4040, The WebhookEvent JSON parser is currently silently
defaulting required fields (id, occurredAt, projectId, purchaseToken,
receivedAt, environment, source, type, platform) which can create synthetic
valid events; update the WebhookEvent.fromJson logic in Types.kt to validate
these required fields instead of providing defaults: for each required field
(refer to id, occurredAt, projectId, purchaseToken, receivedAt, environment
using WebhookEventEnvironment.fromJson, source using
WebhookEventSource.fromJson, type using WebhookEventType.fromJson, and platform
using IapPlatform.fromJson) return an error/null or throw an exception when
missing or unparsable, and only allow nullable/defaulting behavior for truly
optional fields like priceAmountMicros, expiresAt, renewsAt, rawSignedPayload,
subscriptionState, thereby preventing malformed payloads from being treated as
valid events.

Comment on lines +1409 to +1414
/**
* Replay missed webhook events for the authenticated client since the given
* timestamp. SDKs call this on reconnect / foreground entry to backfill events
* that occurred while the WebSocket was closed.
*/
webhookEventsSince: WebhookEvent[];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Query.webhookEventsSince return type is sync but should be async

Line 1414 should be Promise<WebhookEvent[]> to stay consistent with the Query API surface and generated query field helpers.

Suggested fix
-  webhookEventsSince: WebhookEvent[];
+  webhookEventsSince: Promise<WebhookEvent[]>;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@libraries/react-native-iap/src/types.ts` around lines 1409 - 1414, The Query
type's webhookEventsSince field is currently declared as synchronous
(WebhookEvent[]) but should return a Promise to match the Query API surface;
update the declaration of webhookEventsSince in types.ts to return
Promise<WebhookEvent[]> (i.e., change the type from WebhookEvent[] to
Promise<WebhookEvent[]]) and ensure any related generated query field helpers or
consumers of Query.webhookEventsSince are updated to await the result
accordingly so the types remain consistent across the codebase.

Comment on lines +1419 to +1444
public struct WebhookEvent: Codable {
/// Reason for cancellation, when applicable.
public var cancellationReason: WebhookCancellationReason? = nil
/// Localized currency code (ISO 4217) at event time, when available.
public var currency: String? = nil
public var environment: WebhookEventEnvironment
/// When the current subscription period ends. Epoch milliseconds.
public var expiresAt: Double? = nil
/// Stable identifier suitable for idempotency. Derived from the source notification
/// UUID where the store provides one (ASN v2 `notificationUUID`, RTDN message id);
/// otherwise hashed from the canonicalized payload.
public var id: String
/// Time the underlying event occurred at the store. Epoch milliseconds.
public var occurredAt: Double
public var platform: IapPlatform
/// Price in micros (1/1,000,000 of the currency unit) at event time, when available.
/// Matches Google Play's `priceAmountMicros` convention; iOS values are converted.
public var priceAmountMicros: Double? = nil
/// Product the event pertains to. May be null for account-level events.
public var productId: String? = nil
/// kit project that owns the subscription / purchase this event refers to.
public var projectId: String
/// Cross-platform purchase identity used to correlate this event with an existing
/// purchase record. iOS: `originalTransactionId`. Android: `purchaseToken`.
public var purchaseToken: String
/// Original signed payload from the store. ASN v2 events expose the JWS string;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Find the GraphQL schema files
find . -name "*.graphql" -o -name "schema.graphql" | head -20

Repository: hyodotdev/openiap

Length of output: 409


🏁 Script executed:

# Search for WebhookEvent definition in the codebase
rg "enum WebhookEventType|type WebhookEvent" -A 30 | head -100

Repository: hyodotdev/openiap

Length of output: 7249


🏁 Script executed:

# Check for testNotification in WebhookEventType
rg "testNotification" --type graphql -B 2 -A 2

Repository: hyodotdev/openiap

Length of output: 93


🏁 Script executed:

# Verify purchaseToken field definition in GraphQL
rg "purchaseToken" --type graphql -B 2 -A 2 | head -50

Repository: hyodotdev/openiap

Length of output: 93


🏁 Script executed:

# Read the subscription delivery docs around line 2702
sed -n '2695,2710p' packages/apple/Sources/Models/Types.swift

Repository: hyodotdev/openiap

Length of output: 1127


🏁 Script executed:

# Check WebhookEvent.type docs around line 518
sed -n '510,530p' packages/apple/Sources/Models/Types.swift

Repository: hyodotdev/openiap

Length of output: 993


🏁 Script executed:

# Read the full webhook.graphql schema for WebhookEvent type
sed -n '/^type WebhookEvent/,/^}/p' packages/gql/src/webhook.graphql

Repository: hyodotdev/openiap

Length of output: 2155


🏁 Script executed:

# Search for TestNotification in the codebase to verify Android RTDN behavior
rg "TestNotification" -B 5 -A 5

Repository: hyodotdev/openiap

Length of output: 13620


🏁 Script executed:

# Check if there's any special handling for test notifications and purchaseToken
rg "testNotification|test-notification" -B 3 -A 3 | head -80

Repository: hyodotdev/openiap

Length of output: 6711


🏁 Script executed:

# Look at the webhook.graphql file more carefully for any notes about test notifications
cat packages/gql/src/webhook.graphql | grep -A 30 "testNotification"

Repository: hyodotdev/openiap

Length of output: 43


🏁 Script executed:

# Check the webhook mapping documentation for test notification fields
cat knowledge/external/webhook-mapping.md | head -100

Repository: hyodotdev/openiap

Length of output: 5388


🏁 Script executed:

# Search for actual webhook event construction/parsing to see if testNotification events are handled
rg "testNotification|TestNotification" packages/apple -B 5 -A 5

Repository: hyodotdev/openiap

Length of output: 1015


🏁 Script executed:

# Check the webhook payload validation/construction code
rg "purchaseToken" packages/apple -B 3 -A 3 | head -60

Repository: hyodotdev/openiap

Length of output: 4801


🏁 Script executed:

# Check if there's any webhook event parsing or construction code that shows how test notifications are handled
rg "WebhookEvent" packages/apple -B 3 -A 8 | grep -A 10 "test\|Test" | head -40

Repository: hyodotdev/openiap

Length of output: 3634


🏁 Script executed:

# Search for any existing handling of nullable purchaseToken or test notification edge cases
rg "purchaseToken.*null|null.*purchaseToken" packages/apple

Repository: hyodotdev/openiap

Length of output: 43


🏁 Script executed:

# Check the Android documentation reference to confirm test notification payload structure
cat knowledge/external/webhook-mapping.md | grep -A 5 "TestNotification"

Repository: hyodotdev/openiap

Length of output: 249


🏁 Script executed:

# Verify the exact lines cited: check line 2702 for subscription delivery docs
sed -n '2695,2715p' packages/apple/Sources/Models/Types.swift

Repository: hyodotdev/openiap

Length of output: 1299


🏁 Script executed:

# Check around line 518 for WebhookEventType definition
sed -n '505,530p' packages/apple/Sources/Models/Types.swift

Repository: hyodotdev/openiap

Length of output: 1285


🏁 Script executed:

# Verify purchaseToken location at line 1443
sed -n '1438,1448p' packages/apple/Sources/Models/Types.swift

Repository: hyodotdev/openiap

Length of output: 785


🏁 Script executed:

# Double-check the GraphQL schema to confirm purchaseToken is non-nullable
cat packages/gql/src/webhook.graphql | grep -A 3 "purchaseToken"

Repository: hyodotdev/openiap

Length of output: 496


purchaseToken should be nullable for store test notifications.

WebhookEvent.type includes testNotification, but purchaseToken is required (line 1443) while test notifications from RTDN lack this field. This prevents clean representation of test events in the schema. The docs at line 2702 assume universal token-based ownership matching, but test notifications are account-level diagnostic events that don't correlate to a purchase.

Fix this in the GraphQL schema (purchaseToken: StringpurchaseToken: String?), then regenerate. Update the subscription delivery docs (line 2702) to clarify that token-based matching applies to purchase-linked events only.

Also applies to: 518–520, 2701–2707

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/apple/Sources/Models/Types.swift` around lines 1419 - 1444,
WebhookEvent.purchaseToken is currently non-optional but RTDN testNotification
events lack a purchase token; change the GraphQL schema field from
purchaseToken: String to purchaseToken: String? and update the generated Swift
model (WebhookEvent.purchaseToken) to be an optional String, then regenerate the
schema-derived types so all usages accept nil; also update the subscription
delivery docs text that discusses token-based ownership matching (around the
existing paragraph referencing purchase-token matching) to state that
token-based matching applies only to purchase-linked events and not to
account-level test notifications.

Comment on lines +3902 to +3921
fun fromJson(json: Map<String, Any?>): WebhookEvent {
return WebhookEvent(
cancellationReason = (json["cancellationReason"] as? String)?.let { WebhookCancellationReason.fromJson(it) },
currency = json["currency"] as? String,
environment = (json["environment"] as? String)?.let { WebhookEventEnvironment.fromJson(it) } ?: WebhookEventEnvironment.Production,
expiresAt = (json["expiresAt"] as? Number)?.toDouble(),
id = json["id"] as? String ?: "",
occurredAt = (json["occurredAt"] as? Number)?.toDouble() ?: 0.0,
platform = (json["platform"] as? String)?.let { IapPlatform.fromJson(it) } ?: IapPlatform.Ios,
priceAmountMicros = (json["priceAmountMicros"] as? Number)?.toDouble(),
productId = json["productId"] as? String,
projectId = json["projectId"] as? String ?: "",
purchaseToken = json["purchaseToken"] as? String ?: "",
rawSignedPayload = json["rawSignedPayload"] as? String,
receivedAt = (json["receivedAt"] as? Number)?.toDouble() ?: 0.0,
renewsAt = (json["renewsAt"] as? Number)?.toDouble(),
source = (json["source"] as? String)?.let { WebhookEventSource.fromJson(it) } ?: WebhookEventSource.AppleAppStoreServerNotificationsV2,
subscriptionState = (json["subscriptionState"] as? String)?.let { SubscriptionState.fromJson(it) },
type = (json["type"] as? String)?.let { WebhookEventType.fromJson(it) } ?: WebhookEventType.SubscriptionStarted,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Fail fast for missing required WebhookEvent fields instead of defaulting.

WebhookEvent.fromJson currently substitutes missing required fields with empty/zero/default enum values. That can silently emit invalid normalized events and break idempotency/replay semantics (id, occurredAt, purchaseToken, type, etc.). Please make required fields strict (throw on missing/invalid) in the generator template for this model.

Suggested direction (generator output behavior)
- id = json["id"] as? String ?: "",
+ id = json["id"] as? String
+     ?: throw IllegalArgumentException("Missing required field WebhookEvent.id"),

- occurredAt = (json["occurredAt"] as? Number)?.toDouble() ?: 0.0,
+ occurredAt = (json["occurredAt"] as? Number)?.toDouble()
+     ?: throw IllegalArgumentException("Missing required field WebhookEvent.occurredAt"),

- type = (json["type"] as? String)?.let { WebhookEventType.fromJson(it) } ?: WebhookEventType.SubscriptionStarted,
+ type = (json["type"] as? String)?.let { WebhookEventType.fromJson(it) }
+     ?: throw IllegalArgumentException("Missing required field WebhookEvent.type"),

Based on learnings: DO NOT EDIT auto-generated type files (including packages/google/openiap/src/main/.../Types.kt); apply fixes via schema/codegen.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt` around lines
3902 - 3921, WebhookEvent.fromJson currently substitutes required fields with
defaults; change the generator/template so WebhookEvent.fromJson validates and
fails fast by throwing when required fields are missing or invalid (instead of
using ""/0.0/default enums). In particular enforce non-null/non-empty checks for
id, occurredAt, purchaseToken, type (WebhookEventType), projectId/purchaseToken
if required by schema, and any other required properties (e.g., receivedAt if
required) inside the generated WebhookEvent.fromJson function; replace
constructs like 'as? String ?: ""' and 'as? Number ?: 0.0' and default enum
fallbacks with explicit validation that throws a descriptive exception
(including the field name and raw value) so invalid payloads are rejected at
parse time rather than normalized silently. Ensure generator emits these
validation rules for WebhookEvent and similar required models (refer to
WebhookEvent.fromJson, WebhookEventType.fromJson,
WebhookEventEnvironment.fromJson, SubscriptionState.fromJson,
IapPlatform.fromJson).

Comment on lines +80 to +83
User upgraded, downgraded, or crossgraded their plan.
iOS: DID_CHANGE_RENEWAL_PREF.
Android: SUBSCRIPTION_DEFERRED / SUBSCRIPTION_PRODUCT_CHANGED.
"""

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

RTDN labels in enum docs are outdated/inaccurate.

The Android examples mention labels that don’t match current RTDN naming, which can mislead implementers and gets copied into generated SDK docs.

What are the current Google RTDN names for product-change and one-time refund/cancel-related notifications?

Also applies to: 96-99

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/gql/src/webhook.graphql` around lines 80 - 83, The enum docstring
that currently reads "User upgraded, downgraded, or crossgraded their plan. iOS:
DID_CHANGE_RENEWAL_PREF. Android: SUBSCRIPTION_DEFERRED /
SUBSCRIPTION_PRODUCT_CHANGED." contains outdated Android RTDN label names;
update the comment in webhook.graphql for that enum (the doc block containing
"User upgraded, downgraded, or crossgraded their plan" and the nearby doc block
referenced around the same section) to use the current Google RTDN notification
names for product-change and one-time refund/cancel notifications by consulting
Google's RTDN docs and replacing the incorrect labels with the official RTDN
event names so generated SDK docs are accurate.

@hyochan

hyochan commented May 1, 2026

Copy link
Copy Markdown
Member Author

Consolidated into #124. The webhook spec commits () are part of #124's branch; closing this PR so reviewers have one place to look. Re-opening if/when we want to split the spec back into its own change.

@hyochan hyochan closed this May 1, 2026
@hyochan hyochan deleted the feat/webhook-spec branch May 1, 2026 06:16
hyochan added a commit that referenced this pull request May 1, 2026
…ice units + Promise return types

The closed PR #123 had 12 inline review comments from gemini-code-assist
and coderabbitai. Fixing the substantive correctness issues here:

RTDN numeric codes were swapped/incorrect:
- Code 1 = SUBSCRIPTION_RECOVERED, code 4 = SUBSCRIPTION_PURCHASED
  (earlier draft had them reversed). Fixed in
  `convex/webhooks/shared.ts::GOOGLE_SUB_TYPE_MAP`, the unit-test
  expectations, the conformance scenarios, and
  `knowledge/external/webhook-mapping.md`.
- Code 7 = SUBSCRIPTION_RESTARTED was incorrectly mapped to
  `SubscriptionRecovered`. RTDN docs define it as auto-renew
  re-enabled while the period is still active — that matches
  `SubscriptionUncanceled` semantics. Fixed in the map and added an
  explicit test case.
- Code 11 = SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED had its enum
  documentation under `SubscriptionResumed`. RTDN actually fires
  this when the pause schedule is updated; the real resume comes
  back as RECOVERED (1). Moved the doc under `SubscriptionPaused`
  and updated `webhook.graphql` + the mapping table.
- Code 19 = SUBSCRIPTION_PRICE_CHANGE_UPDATED added as alias for
  the existing PRICE_CHANGE_CONFIRMED.

Apple price unit terminology was wrong:
- Apple's `signedTransactionInfo.price` is in **milliunits**
  (1/1000 of a currency unit), not "millicents". $9.99 is 9990
  milliunits. Multiplier to micros is 1000×, not 10×.
- Fixed `normalizeAppleAsn` (price * 10 → price * 1000), the
  terminology + link comment, the test fixture (999_000 → 9_990),
  and the `webhook-mapping.md` formula.

`webhookEventsSince` query missing Promise<> wrap:
- `Query.webhookEventsSince` was generating as
  `webhookEventsSince: WebhookEvent[]` instead of
  `Promise<WebhookEvent[]>`. The TS post-processor only wraps
  fields marked `# Future` in the schema and only scanned
  `api*.graphql` — `webhook.graphql` was excluded.
- Added `# Future` comment in `webhook.graphql` and added
  `webhook.graphql` to `fix-generated-types.mjs`'s `schemaFiles`.

Out of scope for this commit (deferred to follow-up):
- Required-field fail-fast in generated `fromJson` / `from_dict`
  for Kotlin / Dart / GDScript / Swift. The codegen plugins
  currently default missing required fields to empty strings /
  zero / first enum value, which review correctly flagged as
  contract-violation hiding. Fixing requires plugin changes in
  `packages/gql/codegen/plugins/` for all four languages.

Verification:
- kit lint clean (0 errors); 281/281 vitest; smoke green.
- gql 16/16 vitest; rn-iap 276/276 jest; expo-iap 46/46 jest.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
hyochan added a commit that referenced this pull request May 1, 2026
…ers, dedupe SubscriptionState

Three CI checks were red on PR #124. Local verification + fixes for all
three:

1. **Kit Typecheck + Build (Docker frozen-lockfile)**: bun.lock at the
   workspace root was out of sync with `packages/kit/package.json`
   after the recent `google-auth-library` add. Re-ran `bun install`
   so the root lock matches; verified locally with the same
   `bun install --frozen-lockfile --filter @hyodotdev/openiap-kit`
   the Docker step runs.

2. **kmp-iap Compile Check**: my `Subscription.webhookEvent` /
   `Query.webhookEventsSince` GraphQL fields meant the codegen made
   them required interface methods on the device-side IAP class, so
   `InAppPurchaseAndroid` failed to compile (missing
   `suspend fun webhookEventsSince` / `webhookEvent`). Plus my hand-
   written `WebhookEvent` data class collided with the generated
   `Types.kt` one.
   - Removed both fields from `webhook.graphql`. The webhook stream
     is a kit-server feature served over SSE
     (`/v1/webhooks/stream/{apiKey}`), not a GraphQL transport — the
     spec note in `webhook.graphql` documents the call-site contract
     instead.
   - Rewrote `WebhookClient.kt` to use the generated `WebhookEvent`
     data class + every enum from the generated `Types.kt`, with the
     parser falling back through the generated `fromJson` factories
     (KMP codegen emits PascalCase / SCREAMING_SNAKE / kebab-case
     aliases).

3. **Flutter Analyze (`ambiguous_export`)**: `SubscriptionState` was
   defined in both `lib/enums.dart` (hand-written legacy) and
   `lib/types.dart` (auto-generated from `webhook.graphql`).
   - Removed the hand-written enum from `enums.dart` (verified zero
     in-tree usages); the generated one is now the single source.
   - Rewrote `lib/webhook_client.dart` to use the generated
     `WebhookEvent.fromJson` with a fallback that rewrites enum
     fields by their `.name` to the codegen wire format
     (kebab-case). Drops the duplicated `WebhookEventTypeName` enum
     I had hand-defined.
   - Updated `test/webhook_client_test.dart` accordingly: unknown
     event types now correctly return null (PR #123 review's fail-
     fast expectation) instead of mapping to a synthetic `Unknown`.

Cascading cleanup:
- Updated `packages/docs/src/pages/docs/webhooks.tsx` Kotlin / Dart
  examples from `WebhookEventTypeName.subscriptionRenewed` to the
  generated `WebhookEventType.SubscriptionRenewed`.
- Re-ran codegen + sync; generated `Types.swift` / `Types.kt` /
  `types.dart` / `types.gd` / `types.ts` no longer carry the
  webhook Query / Subscription typings.

Local verification (matches CI):
- kit lint clean (0 errors); 281/281 vitest; smoke green.
- `bun install --frozen-lockfile --filter @hyodotdev/openiap-kit` clean.
- KMP `./gradlew :library:compileDebugKotlinAndroid` BUILD SUCCESSFUL.
- KMP `./gradlew :library:testDebugUnitTest` BUILD SUCCESSFUL.
- Flutter `flutter analyze` no issues, `flutter test
  test/webhook_client_test.dart` 3/3 pass.
- react-native-iap 276/276 jest, expo-iap 46/46 jest.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant