feat(gql): add webhook event spec for ASN v2 / RTDN normalization#123
feat(gql): add webhook event spec for ASN v2 / RTDN normalization#123hyochan wants to merge 1 commit into
Conversation
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>
📝 WalkthroughWalkthroughThis 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 Changes
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
Estimated Code Review Effort🎯 4 (Complex) | ⏱️ ~50 minutes Possibly Related PRs
Suggested Labels
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Review rate limit: 0/1 reviews remaining, refill in 60 minutes.Comment |
There was a problem hiding this comment.
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.
| 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 |
There was a problem hiding this comment.
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.
| 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, | ||
| ) | ||
| } |
There was a problem hiding this comment.
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` | |
There was a problem hiding this comment.
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".
There was a problem hiding this comment.
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
⛔ Files ignored due to path filters (5)
packages/gql/src/generated/Types.ktis excluded by!**/generated/**packages/gql/src/generated/Types.swiftis excluded by!**/generated/**packages/gql/src/generated/types.dartis excluded by!**/generated/**packages/gql/src/generated/types.gdis excluded by!**/generated/**packages/gql/src/generated/types.tsis excluded by!**/generated/**
📒 Files selected for processing (11)
knowledge/external/webhook-mapping.mdlibraries/expo-iap/src/types.tslibraries/flutter_inapp_purchase/lib/types.dartlibraries/godot-iap/addons/godot-iap/types.gdlibraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.ktlibraries/react-native-iap/src/types.tspackages/apple/Sources/Models/Types.swiftpackages/google/openiap/src/main/java/dev/hyo/openiap/Types.ktpackages/gql/codegen.tspackages/gql/codegen/core/parser.tspackages/gql/src/webhook.graphql
| | `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) | |
There was a problem hiding this comment.
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` | |
There was a problem hiding this comment.
🧩 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:
- 1: https://developers.google.com/android-publisher/api-ref/rest/v3/purchases.subscriptionsv2/get
- 2: https://developers.google.com/android-publisher/api-ref/rest/v3/Money
- 3: https://developers.google.com/android-publisher/api-ref/rest/v3/Price
- 4: https://googleapis.github.io/google-api-python-client/docs/dyn/androidpublisher_v3.purchases.subscriptions.html
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.
| /** | ||
| * 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[]; |
There was a problem hiding this comment.
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.
| /// 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'), |
There was a problem hiding this comment.
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.
| /// Paused subscription resumed (Android only feature). | ||
| /// Android: SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED / SUBSCRIPTION_RECOVERED from pause. | ||
| SubscriptionResumed('subscription-resumed'), |
There was a problem hiding this comment.
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.
| 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, |
There was a problem hiding this comment.
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.
| /** | ||
| * 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[]; |
There was a problem hiding this comment.
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.
| 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; |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# Find the GraphQL schema files
find . -name "*.graphql" -o -name "schema.graphql" | head -20Repository: hyodotdev/openiap
Length of output: 409
🏁 Script executed:
# Search for WebhookEvent definition in the codebase
rg "enum WebhookEventType|type WebhookEvent" -A 30 | head -100Repository: hyodotdev/openiap
Length of output: 7249
🏁 Script executed:
# Check for testNotification in WebhookEventType
rg "testNotification" --type graphql -B 2 -A 2Repository: hyodotdev/openiap
Length of output: 93
🏁 Script executed:
# Verify purchaseToken field definition in GraphQL
rg "purchaseToken" --type graphql -B 2 -A 2 | head -50Repository: 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.swiftRepository: hyodotdev/openiap
Length of output: 1127
🏁 Script executed:
# Check WebhookEvent.type docs around line 518
sed -n '510,530p' packages/apple/Sources/Models/Types.swiftRepository: 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.graphqlRepository: hyodotdev/openiap
Length of output: 2155
🏁 Script executed:
# Search for TestNotification in the codebase to verify Android RTDN behavior
rg "TestNotification" -B 5 -A 5Repository: 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 -80Repository: 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 -100Repository: 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 5Repository: hyodotdev/openiap
Length of output: 1015
🏁 Script executed:
# Check the webhook payload validation/construction code
rg "purchaseToken" packages/apple -B 3 -A 3 | head -60Repository: 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 -40Repository: 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/appleRepository: 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.swiftRepository: hyodotdev/openiap
Length of output: 1299
🏁 Script executed:
# Check around line 518 for WebhookEventType definition
sed -n '505,530p' packages/apple/Sources/Models/Types.swiftRepository: hyodotdev/openiap
Length of output: 1285
🏁 Script executed:
# Verify purchaseToken location at line 1443
sed -n '1438,1448p' packages/apple/Sources/Models/Types.swiftRepository: 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: String → purchaseToken: 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.
| 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, | ||
| ) |
There was a problem hiding this comment.
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).
| User upgraded, downgraded, or crossgraded their plan. | ||
| iOS: DID_CHANGE_RENEWAL_PREF. | ||
| Android: SUBSCRIPTION_DEFERRED / SUBSCRIPTION_PRODUCT_CHANGED. | ||
| """ |
There was a problem hiding this comment.
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.
…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>
…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>
Summary
packages/gql/src/webhook.graphqldefining a unifiedWebhookEventshape that normalizes Apple ASN v2 and Google RTDN into a single cross-store event surface, streamed to clients via aSubscription.webhookEventfield with aQuery.webhookEventsSincebackfill.knowledge/external/webhook-mapping.mdas the SSOT for the kit receiver implementation that lands in PR Doc mismatch with expo-iap implementation and some confusions #2.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
Test plan
bun run generate— codegen passes for swift / kotlin / dart / gdscript / typescriptbun run sync)bun run audit:docs— no new failures (7 pre-existing warnings unrelated to this PR)knowledge/internal/01-naming-conventions.md🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Documentation