From d0fce824d5e522da1f04755663d69306f101aaf6 Mon Sep 17 00:00:00 2001 From: hyochan Date: Thu, 30 Apr 2026 23:22:23 +0900 Subject: [PATCH] feat(gql): add webhook event spec for ASN v2 / RTDN normalization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- knowledge/external/webhook-mapping.md | 79 ++++ libraries/expo-iap/src/types.ts | 84 ++++ .../flutter_inapp_purchase/lib/types.dart | 356 +++++++++++++++ libraries/godot-iap/addons/godot-iap/types.gd | 336 ++++++++++++++ .../io/github/hyochan/kmpiap/openiap/Types.kt | 410 +++++++++++++++++- libraries/react-native-iap/src/types.ts | 84 ++++ packages/apple/Sources/Models/Types.swift | 161 ++++++- .../src/main/java/dev/hyo/openiap/Types.kt | 381 +++++++++++++++- packages/gql/codegen.ts | 1 + packages/gql/codegen/core/parser.ts | 1 + packages/gql/src/generated/Types.kt | 410 +++++++++++++++++- packages/gql/src/generated/Types.swift | 161 ++++++- packages/gql/src/generated/types.dart | 356 +++++++++++++++ packages/gql/src/generated/types.gd | 336 ++++++++++++++ packages/gql/src/generated/types.ts | 84 ++++ packages/gql/src/webhook.graphql | 236 ++++++++++ 16 files changed, 3466 insertions(+), 10 deletions(-) create mode 100644 knowledge/external/webhook-mapping.md create mode 100644 packages/gql/src/webhook.graphql diff --git a/knowledge/external/webhook-mapping.md b/knowledge/external/webhook-mapping.md new file mode 100644 index 00000000..21c40da6 --- /dev/null +++ b/knowledge/external/webhook-mapping.md @@ -0,0 +1,79 @@ +# Webhook Event Mapping (ASN v2 ↔ RTDN ↔ openiap) + +This document is the source of truth for how kit normalizes Apple App Store Server +Notifications v2 (ASN v2) and Google Play Real-Time Developer Notifications (RTDN) +into the unified `WebhookEvent` shape defined in [`packages/gql/src/webhook.graphql`](../../packages/gql/src/webhook.graphql). + +When kit's webhook receivers are implemented (Phase 1, PR #2), they MUST follow +this table. When extending the spec (new event types, new stores), update this +document in the same PR. + +## Subscription lifecycle + +| openiap `WebhookEventType` | Apple ASN v2 `notificationType` (`subtype`) | Google RTDN `subscriptionNotification.notificationType` | +|---|---|---| +| `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) | +| `SubscriptionCanceled` | `DID_CHANGE_RENEWAL_STATUS` (`AUTO_RENEW_DISABLED`) | `SUBSCRIPTION_CANCELED` (3) | +| `SubscriptionUncanceled` | `DID_CHANGE_RENEWAL_STATUS` (`AUTO_RENEW_ENABLED`) | (no direct equivalent — inferred from `SUBSCRIPTION_RESTARTED` while period still active) | +| `SubscriptionRevoked` | `REVOKE` | `SUBSCRIPTION_REVOKED` (12) | +| `SubscriptionPriceChange` | `PRICE_INCREASE` | `SUBSCRIPTION_PRICE_CHANGE_CONFIRMED` (8) | +| `SubscriptionProductChanged` | `DID_CHANGE_RENEWAL_PREF` | `SUBSCRIPTION_DEFERRED` (9), `SUBSCRIPTION_PRODUCT_CHANGED` (no fixed code) | +| `SubscriptionPaused` | (no equivalent — iOS has no pause) | `SUBSCRIPTION_PAUSED` (10), `SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED` (11) | +| `SubscriptionResumed` | (no equivalent) | `SUBSCRIPTION_RECOVERED` (4) following `SUBSCRIPTION_PAUSED` | + +¹ `SUBSCRIPTION_RECOVERED` (RTDN code 4) maps to either `SubscriptionStarted` +(when no prior active period existed) or `SubscriptionRecovered` (when recovering +from grace/hold/pause). kit decides based on the prior state in its +`subscriptions` table. + +## One-time / common + +| openiap `WebhookEventType` | Apple ASN v2 | Google RTDN | +|---|---|---| +| `PurchaseRefunded` | `REFUND` | `oneTimeProductNotification.notificationType = ONE_TIME_PRODUCT_CANCELED` (2), or `voidedPurchaseNotification` | +| `PurchaseConsumptionRequest` | `CONSUMPTION_REQUEST` | (no equivalent — Play handles consumption client-side) | +| `TestNotification` | `TEST` | `testNotification` field present on the RTDN message | + +## Field mapping + +| `WebhookEvent` field | Apple ASN v2 source | Google RTDN source | +|---|---|---| +| `id` | `notificationUUID` | Pub/Sub `messageId` | +| `occurredAt` | `signedDate` | `eventTimeMillis` | +| `environment` | `data.environment` (`Production` \| `Sandbox` \| `Xcode`) | `testNotification` present → `Sandbox`, else `Production` | +| `purchaseToken` | `data.signedTransactionInfo.originalTransactionId` | `subscriptionNotification.purchaseToken` or `oneTimeProductNotification.purchaseToken` | +| `productId` | `data.signedTransactionInfo.productId` | `subscriptionNotification.subscriptionId` or `oneTimeProductNotification.sku` | +| `expiresAt` | `data.signedRenewalInfo.expirationDate` (decoded JWS) | resolved by calling `purchases.subscriptionsv2.get` (ASN/RTDN do not embed it directly) | +| `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` | +| `rawSignedPayload` | The complete `signedPayload` JWS string from the ASN body | The base64-decoded Pub/Sub message `data` (JSON) | + +## Validation requirements (kit Phase 1, PR #2) + +Both stores require signature verification before any event is emitted: + +- **Apple ASN v2**: verify the JWS using Apple's public root certificates (refresh + via the App Store Connect API). The receiver must reject unverified payloads + with HTTP 401. +- **Google RTDN**: validate the Pub/Sub push request against the configured + service account audience (OIDC token verification). Reject missing or invalid + tokens with HTTP 401. + +Idempotency: + +- Use `(source, sourceNotificationId)` as the dedup key, where + `sourceNotificationId` is `notificationUUID` for ASN v2 or `messageId` for + RTDN. Convex idempotency table records the first-seen event and silently + acknowledges duplicates with HTTP 200. + +Replay window: + +- Events MUST be retained for at least 30 days so `webhookEventsSince` can + service reconnecting clients. Older events are pruned by a Convex cron job. diff --git a/libraries/expo-iap/src/types.ts b/libraries/expo-iap/src/types.ts index 48f0103b..cc3408f3 100644 --- a/libraries/expo-iap/src/types.ts +++ b/libraries/expo-iap/src/types.ts @@ -1406,6 +1406,12 @@ export interface Query { * @deprecated Use verifyPurchase */ validateReceiptIOS: Promise; + /** + * 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[]; } @@ -1434,6 +1440,11 @@ export type QuerySubscriptionStatusIosArgs = string; export type QueryValidateReceiptIosArgs = VerifyPurchaseProps; +export interface QueryWebhookEventsSinceArgs { + limit?: (number | null); + sinceMs: number; +} + export interface RefundResultIOS { message?: (string | null); status: string; @@ -1751,6 +1762,16 @@ export interface Subscription { * Only triggered when the user selects alternative billing instead of Google Play billing */ userChoiceBillingAndroid: UserChoiceBillingDetails; + /** + * Streams normalized webhook events tied to the authenticated client's purchases. + * Clients only receive events whose `purchaseToken` matches a purchase they own. + * + * Transport: kit serves this over WebSocket. SDKs auto-connect when the host app + * enters foreground and disconnect when it goes to background. Events that fire + * while the connection is closed are reconciled via `webhookEventsSince` on + * reconnect or the next foreground entry. + */ + webhookEvent: WebhookEvent; } @@ -1896,6 +1917,8 @@ export interface SubscriptionProductReplacementParamsAndroid { */ export type SubscriptionReplacementModeAndroid = 'unknown-replacement-mode' | 'with-time-proration' | 'charge-prorated-price' | 'charge-full-price' | 'without-proration' | 'deferred' | 'keep-existing'; +export type SubscriptionState = 'active' | 'expired' | 'in-billing-retry' | 'in-grace-period' | 'paused' | 'refunded' | 'revoked' | 'unknown'; + export interface SubscriptionStatusIOS { renewalInfo?: (RenewalInfoIOS | null); state: string; @@ -2057,6 +2080,65 @@ export interface VerifyPurchaseWithProviderResult { export type VoidResult = void; +export type WebhookCancellationReason = 'billing-error' | 'other' | 'price-increase-declined' | 'product-unavailable' | 'refunded' | 'user-canceled'; + +export interface WebhookEvent { + /** Reason for cancellation, when applicable. */ + cancellationReason?: (WebhookCancellationReason | null); + /** Localized currency code (ISO 4217) at event time, when available. */ + currency?: (string | null); + environment: WebhookEventEnvironment; + /** When the current subscription period ends. Epoch milliseconds. */ + expiresAt?: (number | null); + /** + * 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. + */ + id: string; + /** Time the underlying event occurred at the store. Epoch milliseconds. */ + occurredAt: number; + 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. + */ + priceAmountMicros?: (number | null); + /** Product the event pertains to. May be null for account-level events. */ + productId?: (string | null); + /** kit project that owns the subscription / purchase this event refers to. */ + projectId: string; + /** + * Cross-platform purchase identity used to correlate this event with an existing + * purchase record. iOS: `originalTransactionId`. Android: `purchaseToken`. + */ + purchaseToken: string; + /** + * Original signed payload from the store. ASN v2 events expose the JWS string; + * RTDN events expose the base64-decoded Pub/Sub message JSON. Provided so that + * consumers can independently verify or extract platform-specific fields. kit + * always validates this payload before emitting the event. + */ + rawSignedPayload?: (string | null); + /** Time kit ingested and normalized this event. Epoch milliseconds. */ + receivedAt: number; + /** When auto-renewal will charge again. Epoch milliseconds. */ + renewsAt?: (number | null); + source: WebhookEventSource; + /** + * Normalized subscription state at the time of event, when the event refers to + * a subscription. Null for one-time purchase events. + */ + subscriptionState?: (SubscriptionState | null); + type: WebhookEventType; +} + +export type WebhookEventEnvironment = 'production' | 'sandbox' | 'xcode'; + +export type WebhookEventSource = 'apple-app-store-server-notifications-v2' | 'google-play-real-time-developer-notifications'; + +export type WebhookEventType = 'purchase-consumption-request' | 'purchase-refunded' | 'subscription-canceled' | 'subscription-expired' | 'subscription-in-billing-retry' | 'subscription-in-grace-period' | 'subscription-paused' | 'subscription-price-change' | 'subscription-product-changed' | 'subscription-recovered' | 'subscription-renewed' | 'subscription-resumed' | 'subscription-revoked' | 'subscription-started' | 'subscription-uncanceled' | 'test-notification'; + /** * Win-back offer input for iOS 18+ (StoreKit 2) * Win-back offers are used to re-engage churned subscribers. @@ -2090,6 +2172,7 @@ export type QueryArgsMap = { latestTransactionIOS: QueryLatestTransactionIosArgs; subscriptionStatusIOS: QuerySubscriptionStatusIosArgs; validateReceiptIOS: QueryValidateReceiptIosArgs; + webhookEventsSince: QueryWebhookEventsSinceArgs; }; export type QueryField = @@ -2154,6 +2237,7 @@ export type SubscriptionArgsMap = { purchaseUpdated: never; subscriptionBillingIssue: never; userChoiceBillingAndroid: never; + webhookEvent: never; }; export type SubscriptionField = diff --git a/libraries/flutter_inapp_purchase/lib/types.dart b/libraries/flutter_inapp_purchase/lib/types.dart index 1645922e..e9af67a7 100644 --- a/libraries/flutter_inapp_purchase/lib/types.dart +++ b/libraries/flutter_inapp_purchase/lib/types.dart @@ -928,6 +928,232 @@ enum SubscriptionReplacementModeAndroid { String toJson() => value; } +enum SubscriptionState { + Active('active'), + InGracePeriod('in-grace-period'), + InBillingRetry('in-billing-retry'), + Expired('expired'), + Revoked('revoked'), + Refunded('refunded'), + Paused('paused'), + Unknown('unknown'); + + const SubscriptionState(this.value); + final String value; + + factory SubscriptionState.fromJson(String value) { + final normalized = value.toLowerCase().replaceAll('_', '-'); + switch (normalized) { + case 'active': + return SubscriptionState.Active; + case 'in-grace-period': + return SubscriptionState.InGracePeriod; + case 'in-billing-retry': + return SubscriptionState.InBillingRetry; + case 'expired': + return SubscriptionState.Expired; + case 'revoked': + return SubscriptionState.Revoked; + case 'refunded': + return SubscriptionState.Refunded; + case 'paused': + return SubscriptionState.Paused; + case 'unknown': + return SubscriptionState.Unknown; + } + throw ArgumentError('Unknown SubscriptionState value: $value'); + } + + String toJson() => value; +} + +enum WebhookCancellationReason { + UserCanceled('user-canceled'), + BillingError('billing-error'), + PriceIncreaseDeclined('price-increase-declined'), + ProductUnavailable('product-unavailable'), + Refunded('refunded'), + Other('other'); + + const WebhookCancellationReason(this.value); + final String value; + + factory WebhookCancellationReason.fromJson(String value) { + final normalized = value.toLowerCase().replaceAll('_', '-'); + switch (normalized) { + case 'user-canceled': + return WebhookCancellationReason.UserCanceled; + case 'billing-error': + return WebhookCancellationReason.BillingError; + case 'price-increase-declined': + return WebhookCancellationReason.PriceIncreaseDeclined; + case 'product-unavailable': + return WebhookCancellationReason.ProductUnavailable; + case 'refunded': + return WebhookCancellationReason.Refunded; + case 'other': + return WebhookCancellationReason.Other; + } + throw ArgumentError('Unknown WebhookCancellationReason value: $value'); + } + + String toJson() => value; +} + +enum WebhookEventEnvironment { + Production('production'), + Sandbox('sandbox'), + Xcode('xcode'); + + const WebhookEventEnvironment(this.value); + final String value; + + factory WebhookEventEnvironment.fromJson(String value) { + final normalized = value.toLowerCase().replaceAll('_', '-'); + switch (normalized) { + case 'production': + return WebhookEventEnvironment.Production; + case 'sandbox': + return WebhookEventEnvironment.Sandbox; + case 'xcode': + return WebhookEventEnvironment.Xcode; + } + throw ArgumentError('Unknown WebhookEventEnvironment value: $value'); + } + + String toJson() => value; +} + +enum WebhookEventSource { + AppleAppStoreServerNotificationsV2('apple-app-store-server-notifications-v2'), + GooglePlayRealTimeDeveloperNotifications('google-play-real-time-developer-notifications'); + + const WebhookEventSource(this.value); + final String value; + + factory WebhookEventSource.fromJson(String value) { + final normalized = value.toLowerCase().replaceAll('_', '-'); + switch (normalized) { + case 'apple-app-store-server-notifications-v2': + return WebhookEventSource.AppleAppStoreServerNotificationsV2; + case 'google-play-real-time-developer-notifications': + return WebhookEventSource.GooglePlayRealTimeDeveloperNotifications; + } + throw ArgumentError('Unknown WebhookEventSource value: $value'); + } + + String toJson() => value; +} + +enum WebhookEventType { + /// Initial purchase or first conversion from a free trial / intro offer. + /// iOS: SUBSCRIBED (initialBuy / resubscribe). + /// Android: SUBSCRIPTION_PURCHASED. + SubscriptionStarted('subscription-started'), + /// Auto-renewal succeeded for an existing subscription. + /// iOS: DID_RENEW. + /// Android: SUBSCRIPTION_RENEWED. + SubscriptionRenewed('subscription-renewed'), + /// Subscription reached its expiration without a successful renewal. + /// iOS: EXPIRED. + /// Android: SUBSCRIPTION_EXPIRED. + SubscriptionExpired('subscription-expired'), + /// Billing failed; the subscription is in a grace period during which the user + /// retains entitlement while payment is retried. + /// iOS: DID_FAIL_TO_RENEW (with grace period active). + /// Android: SUBSCRIPTION_IN_GRACE_PERIOD. + SubscriptionInGracePeriod('subscription-in-grace-period'), + /// Billing failed and the subscription is in account-hold / billing retry, + /// during which entitlement is paused but the subscription is not yet expired. + /// iOS: DID_FAIL_TO_RENEW (no grace period; billing retry). + /// Android: SUBSCRIPTION_ON_HOLD. + SubscriptionInBillingRetry('subscription-in-billing-retry'), + /// 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'), + /// Access immediately revoked (family sharing removal, admin action, fraud). + /// iOS: REVOKE. + /// Android: SUBSCRIPTION_REVOKED. + SubscriptionRevoked('subscription-revoked'), + /// A price change is pending or has been confirmed by the user. + /// iOS: PRICE_INCREASE. + /// Android: SUBSCRIPTION_PRICE_CHANGE_CONFIRMED. + SubscriptionPriceChange('subscription-price-change'), + /// User upgraded, downgraded, or crossgraded their plan. + /// iOS: DID_CHANGE_RENEWAL_PREF. + /// Android: SUBSCRIPTION_DEFERRED / SUBSCRIPTION_PRODUCT_CHANGED. + SubscriptionProductChanged('subscription-product-changed'), + /// Subscription paused (Android only feature). + /// Android: SUBSCRIPTION_PAUSED. + SubscriptionPaused('subscription-paused'), + /// Paused subscription resumed (Android only feature). + /// Android: SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED / SUBSCRIPTION_RECOVERED from pause. + SubscriptionResumed('subscription-resumed'), + /// Refund issued for a one-time purchase or subscription period. + /// iOS: REFUND. + /// Android: ONE_TIME_PRODUCT_REFUNDED / VOIDED_PURCHASE. + PurchaseRefunded('purchase-refunded'), + /// iOS-only: App Store requests a consumption status report for a refund decision. + /// Servers should respond via the StoreKit consumption API. + PurchaseConsumptionRequest('purchase-consumption-request'), + /// Sandbox or test notification fired by the store for diagnostic purposes. + /// Useful for verifying webhook plumbing without a live transaction. + TestNotification('test-notification'); + + const WebhookEventType(this.value); + final String value; + + factory WebhookEventType.fromJson(String value) { + final normalized = value.toLowerCase().replaceAll('_', '-'); + switch (normalized) { + case 'subscription-started': + return WebhookEventType.SubscriptionStarted; + case 'subscription-renewed': + return WebhookEventType.SubscriptionRenewed; + case 'subscription-expired': + return WebhookEventType.SubscriptionExpired; + case 'subscription-in-grace-period': + return WebhookEventType.SubscriptionInGracePeriod; + case 'subscription-in-billing-retry': + return WebhookEventType.SubscriptionInBillingRetry; + case 'subscription-recovered': + return WebhookEventType.SubscriptionRecovered; + case 'subscription-canceled': + return WebhookEventType.SubscriptionCanceled; + case 'subscription-uncanceled': + return WebhookEventType.SubscriptionUncanceled; + case 'subscription-revoked': + return WebhookEventType.SubscriptionRevoked; + case 'subscription-price-change': + return WebhookEventType.SubscriptionPriceChange; + case 'subscription-product-changed': + return WebhookEventType.SubscriptionProductChanged; + case 'subscription-paused': + return WebhookEventType.SubscriptionPaused; + case 'subscription-resumed': + return WebhookEventType.SubscriptionResumed; + case 'purchase-refunded': + return WebhookEventType.PurchaseRefunded; + case 'purchase-consumption-request': + return WebhookEventType.PurchaseConsumptionRequest; + case 'test-notification': + return WebhookEventType.TestNotification; + } + throw ArgumentError('Unknown WebhookEventType value: $value'); + } + + String toJson() => value; +} + // MARK: - Interfaces abstract class ProductCommon { @@ -3689,6 +3915,112 @@ class VerifyPurchaseWithProviderResult { typedef VoidResult = void; +class WebhookEvent { + const WebhookEvent({ + this.cancellationReason, + this.currency, + required this.environment, + this.expiresAt, + required this.id, + required this.occurredAt, + required this.platform, + this.priceAmountMicros, + this.productId, + required this.projectId, + required this.purchaseToken, + this.rawSignedPayload, + required this.receivedAt, + this.renewsAt, + required this.source, + this.subscriptionState, + required this.type, + }); + + /// Reason for cancellation, when applicable. + final WebhookCancellationReason? cancellationReason; + /// Localized currency code (ISO 4217) at event time, when available. + final String? currency; + final WebhookEventEnvironment environment; + /// When the current subscription period ends. Epoch milliseconds. + final double? expiresAt; + /// 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. + final String id; + /// Time the underlying event occurred at the store. Epoch milliseconds. + final double occurredAt; + final IapPlatform platform; + /// 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. + final double? priceAmountMicros; + /// Product the event pertains to. May be null for account-level events. + final String? productId; + /// kit project that owns the subscription / purchase this event refers to. + final String projectId; + /// Cross-platform purchase identity used to correlate this event with an existing + /// purchase record. iOS: `originalTransactionId`. Android: `purchaseToken`. + final String purchaseToken; + /// Original signed payload from the store. ASN v2 events expose the JWS string; + /// RTDN events expose the base64-decoded Pub/Sub message JSON. Provided so that + /// consumers can independently verify or extract platform-specific fields. kit + /// always validates this payload before emitting the event. + final String? rawSignedPayload; + /// Time kit ingested and normalized this event. Epoch milliseconds. + final double receivedAt; + /// When auto-renewal will charge again. Epoch milliseconds. + final double? renewsAt; + final WebhookEventSource source; + /// Normalized subscription state at the time of event, when the event refers to + /// a subscription. Null for one-time purchase events. + final SubscriptionState? subscriptionState; + final WebhookEventType type; + + factory WebhookEvent.fromJson(Map json) { + return WebhookEvent( + cancellationReason: json['cancellationReason'] != null ? WebhookCancellationReason.fromJson(json['cancellationReason'] as String) : null, + currency: json['currency'] as String?, + environment: WebhookEventEnvironment.fromJson(json['environment'] as String), + expiresAt: (json['expiresAt'] as num?)?.toDouble(), + id: json['id'] as String, + occurredAt: (json['occurredAt'] as num).toDouble(), + platform: IapPlatform.fromJson(json['platform'] as String), + priceAmountMicros: (json['priceAmountMicros'] as num?)?.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 num).toDouble(), + renewsAt: (json['renewsAt'] as num?)?.toDouble(), + source: WebhookEventSource.fromJson(json['source'] as String), + subscriptionState: json['subscriptionState'] != null ? SubscriptionState.fromJson(json['subscriptionState'] as String) : null, + type: WebhookEventType.fromJson(json['type'] as String), + ); + } + + Map toJson() { + return { + '__typename': 'WebhookEvent', + 'cancellationReason': cancellationReason?.toJson(), + 'currency': currency, + 'environment': environment.toJson(), + 'expiresAt': expiresAt, + 'id': id, + 'occurredAt': occurredAt, + 'platform': platform.toJson(), + 'priceAmountMicros': priceAmountMicros, + 'productId': productId, + 'projectId': projectId, + 'purchaseToken': purchaseToken, + 'rawSignedPayload': rawSignedPayload, + 'receivedAt': receivedAt, + 'renewsAt': renewsAt, + 'source': source.toJson(), + 'subscriptionState': subscriptionState?.toJson(), + 'type': type.toJson(), + }; + } +} + // MARK: - Input Objects class AndroidSubscriptionOfferInput { @@ -5074,6 +5406,13 @@ abstract class QueryResolver { VerifyPurchaseGoogleOptions? google, VerifyPurchaseHorizonOptions? horizon, }); + /// 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. + Future> webhookEventsSince({ + required double sinceMs, + int? limit, + }); } /// GraphQL root subscription operations. @@ -5105,6 +5444,14 @@ abstract class SubscriptionResolver { /// Fires when a user selects alternative billing in the User Choice Billing dialog (Android only) /// Only triggered when the user selects alternative billing instead of Google Play billing Future userChoiceBillingAndroid(); + /// Streams normalized webhook events tied to the authenticated client's purchases. + /// Clients only receive events whose `purchaseToken` matches a purchase they own. + /// + /// Transport: kit serves this over WebSocket. SDKs auto-connect when the host app + /// enters foreground and disconnect when it goes to background. Events that fire + /// while the connection is closed are reconciled via `webhookEventsSince` on + /// reconnect or the next foreground entry. + Future webhookEvent(); } // MARK: - Root Operation Helpers @@ -5255,6 +5602,10 @@ typedef QueryValidateReceiptIOSHandler = Future Functio VerifyPurchaseGoogleOptions? google, VerifyPurchaseHorizonOptions? horizon, }); +typedef QueryWebhookEventsSinceHandler = Future> Function({ + required double sinceMs, + int? limit, +}); class QueryHandlers { const QueryHandlers({ @@ -5279,6 +5630,7 @@ class QueryHandlers { this.latestTransactionIOS, this.subscriptionStatusIOS, this.validateReceiptIOS, + this.webhookEventsSince, }); final QueryCanPresentExternalPurchaseNoticeIOSHandler? canPresentExternalPurchaseNoticeIOS; @@ -5302,6 +5654,7 @@ class QueryHandlers { final QueryLatestTransactionIOSHandler? latestTransactionIOS; final QuerySubscriptionStatusIOSHandler? subscriptionStatusIOS; final QueryValidateReceiptIOSHandler? validateReceiptIOS; + final QueryWebhookEventsSinceHandler? webhookEventsSince; } // MARK: - Subscription Helpers @@ -5312,6 +5665,7 @@ typedef SubscriptionPurchaseErrorHandler = Future Function(); typedef SubscriptionPurchaseUpdatedHandler = Future Function(); typedef SubscriptionSubscriptionBillingIssueHandler = Future Function(); typedef SubscriptionUserChoiceBillingAndroidHandler = Future Function(); +typedef SubscriptionWebhookEventHandler = Future Function(); class SubscriptionHandlers { const SubscriptionHandlers({ @@ -5321,6 +5675,7 @@ class SubscriptionHandlers { this.purchaseUpdated, this.subscriptionBillingIssue, this.userChoiceBillingAndroid, + this.webhookEvent, }); final SubscriptionDeveloperProvidedBillingAndroidHandler? developerProvidedBillingAndroid; @@ -5329,4 +5684,5 @@ class SubscriptionHandlers { final SubscriptionPurchaseUpdatedHandler? purchaseUpdated; final SubscriptionSubscriptionBillingIssueHandler? subscriptionBillingIssue; final SubscriptionUserChoiceBillingAndroidHandler? userChoiceBillingAndroid; + final SubscriptionWebhookEventHandler? webhookEvent; } diff --git a/libraries/godot-iap/addons/godot-iap/types.gd b/libraries/godot-iap/addons/godot-iap/types.gd index bc96928f..5e9c3ed3 100644 --- a/libraries/godot-iap/addons/godot-iap/types.gd +++ b/libraries/godot-iap/addons/godot-iap/types.gd @@ -295,6 +295,72 @@ enum SubscriptionReplacementModeAndroid { KEEP_EXISTING = 6, } +enum SubscriptionState { + ACTIVE = 0, + IN_GRACE_PERIOD = 1, + IN_BILLING_RETRY = 2, + EXPIRED = 3, + REVOKED = 4, + REFUNDED = 5, + PAUSED = 6, + UNKNOWN = 7, +} + +enum WebhookCancellationReason { + USER_CANCELED = 0, + BILLING_ERROR = 1, + PRICE_INCREASE_DECLINED = 2, + PRODUCT_UNAVAILABLE = 3, + REFUNDED = 4, + OTHER = 5, +} + +enum WebhookEventEnvironment { + PRODUCTION = 0, + SANDBOX = 1, + XCODE = 2, +} + +enum WebhookEventSource { + APPLE_APP_STORE_SERVER_NOTIFICATIONS_V2 = 0, + GOOGLE_PLAY_REAL_TIME_DEVELOPER_NOTIFICATIONS = 1, +} + +enum WebhookEventType { + ## Initial purchase or first conversion from a free trial / intro offer. iOS: SUBSCRIBED (initialBuy / resubscribe). Android: SUBSCRIPTION_PURCHASED. + SUBSCRIPTION_STARTED = 0, + ## Auto-renewal succeeded for an existing subscription. iOS: DID_RENEW. Android: SUBSCRIPTION_RENEWED. + SUBSCRIPTION_RENEWED = 1, + ## Subscription reached its expiration without a successful renewal. iOS: EXPIRED. Android: SUBSCRIPTION_EXPIRED. + SUBSCRIPTION_EXPIRED = 2, + ## Billing failed; the subscription is in a grace period during which the user retains entitlement while payment is retried. iOS: DID_FAIL_TO_RENEW (with grace period active). Android: SUBSCRIPTION_IN_GRACE_PERIOD. + SUBSCRIPTION_IN_GRACE_PERIOD = 3, + ## Billing failed and the subscription is in account-hold / billing retry, during which entitlement is paused but the subscription is not yet expired. iOS: DID_FAIL_TO_RENEW (no grace period; billing retry). Android: SUBSCRIPTION_ON_HOLD. + SUBSCRIPTION_IN_BILLING_RETRY = 4, + ## Subscription returned to active state after a billing issue or pause. iOS: DID_RECOVER. Android: SUBSCRIPTION_RECOVERED / SUBSCRIPTION_RESTARTED. + SUBSCRIPTION_RECOVERED = 5, + ## User turned off auto-renew. Access continues until the current period ends. iOS: DID_CHANGE_RENEWAL_STATUS (autoRenew turned off). Android: SUBSCRIPTION_CANCELED. + SUBSCRIPTION_CANCELED = 6, + ## 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). + SUBSCRIPTION_UNCANCELED = 7, + ## Access immediately revoked (family sharing removal, admin action, fraud). iOS: REVOKE. Android: SUBSCRIPTION_REVOKED. + SUBSCRIPTION_REVOKED = 8, + ## A price change is pending or has been confirmed by the user. iOS: PRICE_INCREASE. Android: SUBSCRIPTION_PRICE_CHANGE_CONFIRMED. + SUBSCRIPTION_PRICE_CHANGE = 9, + ## User upgraded, downgraded, or crossgraded their plan. iOS: DID_CHANGE_RENEWAL_PREF. Android: SUBSCRIPTION_DEFERRED / SUBSCRIPTION_PRODUCT_CHANGED. + SUBSCRIPTION_PRODUCT_CHANGED = 10, + ## Subscription paused (Android only feature). Android: SUBSCRIPTION_PAUSED. + SUBSCRIPTION_PAUSED = 11, + ## Paused subscription resumed (Android only feature). Android: SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED / SUBSCRIPTION_RECOVERED from pause. + SUBSCRIPTION_RESUMED = 12, + ## Refund issued for a one-time purchase or subscription period. iOS: REFUND. Android: ONE_TIME_PRODUCT_REFUNDED / VOIDED_PURCHASE. + PURCHASE_REFUNDED = 13, + ## iOS-only: App Store requests a consumption status report for a refund decision. Servers should respond via the StoreKit consumption API. + PURCHASE_CONSUMPTION_REQUEST = 14, + ## Sandbox or test notification fired by the store for diagnostic purposes. Useful for verifying webhook plumbing without a live transaction. + TEST_NOTIFICATION = 15, +} + # ============================================================================ # Types # ============================================================================ @@ -3238,6 +3304,145 @@ class VoidResult: dict["success"] = success return dict +class WebhookEvent: + ## Stable identifier suitable for idempotency. Derived from the source notification + var id: String = "" + var type: WebhookEventType + var source: WebhookEventSource + var platform: IapPlatform + ## kit project that owns the subscription / purchase this event refers to. + var project_id: String = "" + ## Time the underlying event occurred at the store. Epoch milliseconds. + var occurred_at: float = 0.0 + ## Time kit ingested and normalized this event. Epoch milliseconds. + var received_at: float = 0.0 + var environment: WebhookEventEnvironment + ## Cross-platform purchase identity used to correlate this event with an existing + var purchase_token: String = "" + ## Product the event pertains to. May be null for account-level events. + var product_id: Variant = null + ## Normalized subscription state at the time of event, when the event refers to + var subscription_state: SubscriptionState + ## When the current subscription period ends. Epoch milliseconds. + var expires_at: Variant = null + ## When auto-renewal will charge again. Epoch milliseconds. + var renews_at: Variant = null + ## Reason for cancellation, when applicable. + var cancellation_reason: WebhookCancellationReason + ## Localized currency code (ISO 4217) at event time, when available. + var currency: Variant = null + ## Price in micros (1/1,000,000 of the currency unit) at event time, when available. + var price_amount_micros: Variant = null + ## Original signed payload from the store. ASN v2 events expose the JWS string; + var raw_signed_payload: Variant = null + + 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 + + func to_dict() -> Dictionary: + var dict = {} + dict["id"] = id + if WEBHOOK_EVENT_TYPE_VALUES.has(type): + dict["type"] = WEBHOOK_EVENT_TYPE_VALUES[type] + else: + dict["type"] = type + if WEBHOOK_EVENT_SOURCE_VALUES.has(source): + dict["source"] = WEBHOOK_EVENT_SOURCE_VALUES[source] + else: + dict["source"] = source + if IAP_PLATFORM_VALUES.has(platform): + dict["platform"] = IAP_PLATFORM_VALUES[platform] + else: + dict["platform"] = platform + dict["projectId"] = project_id + dict["occurredAt"] = occurred_at + dict["receivedAt"] = received_at + if WEBHOOK_EVENT_ENVIRONMENT_VALUES.has(environment): + dict["environment"] = WEBHOOK_EVENT_ENVIRONMENT_VALUES[environment] + else: + dict["environment"] = environment + dict["purchaseToken"] = purchase_token + if product_id != null: + dict["productId"] = product_id + if SUBSCRIPTION_STATE_VALUES.has(subscription_state): + dict["subscriptionState"] = SUBSCRIPTION_STATE_VALUES[subscription_state] + else: + dict["subscriptionState"] = subscription_state + if expires_at != null: + dict["expiresAt"] = expires_at + if renews_at != null: + dict["renewsAt"] = renews_at + if WEBHOOK_CANCELLATION_REASON_VALUES.has(cancellation_reason): + dict["cancellationReason"] = WEBHOOK_CANCELLATION_REASON_VALUES[cancellation_reason] + else: + dict["cancellationReason"] = cancellation_reason + if currency != null: + dict["currency"] = currency + if price_amount_micros != null: + dict["priceAmountMicros"] = price_amount_micros + if raw_signed_payload != null: + dict["rawSignedPayload"] = raw_signed_payload + return dict + # ============================================================================ # Input Types # ============================================================================ @@ -4569,6 +4774,56 @@ const SUBSCRIPTION_REPLACEMENT_MODE_ANDROID_VALUES = { SubscriptionReplacementModeAndroid.KEEP_EXISTING: "keep-existing" } +const SUBSCRIPTION_STATE_VALUES = { + SubscriptionState.ACTIVE: "active", + SubscriptionState.IN_GRACE_PERIOD: "in-grace-period", + SubscriptionState.IN_BILLING_RETRY: "in-billing-retry", + SubscriptionState.EXPIRED: "expired", + SubscriptionState.REVOKED: "revoked", + SubscriptionState.REFUNDED: "refunded", + SubscriptionState.PAUSED: "paused", + SubscriptionState.UNKNOWN: "unknown" +} + +const WEBHOOK_CANCELLATION_REASON_VALUES = { + WebhookCancellationReason.USER_CANCELED: "user-canceled", + WebhookCancellationReason.BILLING_ERROR: "billing-error", + WebhookCancellationReason.PRICE_INCREASE_DECLINED: "price-increase-declined", + WebhookCancellationReason.PRODUCT_UNAVAILABLE: "product-unavailable", + WebhookCancellationReason.REFUNDED: "refunded", + WebhookCancellationReason.OTHER: "other" +} + +const WEBHOOK_EVENT_ENVIRONMENT_VALUES = { + WebhookEventEnvironment.PRODUCTION: "production", + WebhookEventEnvironment.SANDBOX: "sandbox", + WebhookEventEnvironment.XCODE: "xcode" +} + +const WEBHOOK_EVENT_SOURCE_VALUES = { + WebhookEventSource.APPLE_APP_STORE_SERVER_NOTIFICATIONS_V2: "apple-app-store-server-notifications-v2", + WebhookEventSource.GOOGLE_PLAY_REAL_TIME_DEVELOPER_NOTIFICATIONS: "google-play-real-time-developer-notifications" +} + +const WEBHOOK_EVENT_TYPE_VALUES = { + WebhookEventType.SUBSCRIPTION_STARTED: "subscription-started", + WebhookEventType.SUBSCRIPTION_RENEWED: "subscription-renewed", + WebhookEventType.SUBSCRIPTION_EXPIRED: "subscription-expired", + WebhookEventType.SUBSCRIPTION_IN_GRACE_PERIOD: "subscription-in-grace-period", + WebhookEventType.SUBSCRIPTION_IN_BILLING_RETRY: "subscription-in-billing-retry", + WebhookEventType.SUBSCRIPTION_RECOVERED: "subscription-recovered", + WebhookEventType.SUBSCRIPTION_CANCELED: "subscription-canceled", + WebhookEventType.SUBSCRIPTION_UNCANCELED: "subscription-uncanceled", + WebhookEventType.SUBSCRIPTION_REVOKED: "subscription-revoked", + WebhookEventType.SUBSCRIPTION_PRICE_CHANGE: "subscription-price-change", + WebhookEventType.SUBSCRIPTION_PRODUCT_CHANGED: "subscription-product-changed", + WebhookEventType.SUBSCRIPTION_PAUSED: "subscription-paused", + WebhookEventType.SUBSCRIPTION_RESUMED: "subscription-resumed", + WebhookEventType.PURCHASE_REFUNDED: "purchase-refunded", + WebhookEventType.PURCHASE_CONSUMPTION_REQUEST: "purchase-consumption-request", + WebhookEventType.TEST_NOTIFICATION: "test-notification" +} + # ============================================================================ # Enum Reverse Lookup (string -> enum for deserialization) # ============================================================================ @@ -4787,6 +5042,56 @@ const SUBSCRIPTION_REPLACEMENT_MODE_ANDROID_FROM_STRING = { "keep-existing": SubscriptionReplacementModeAndroid.KEEP_EXISTING } +const SUBSCRIPTION_STATE_FROM_STRING = { + "active": SubscriptionState.ACTIVE, + "in-grace-period": SubscriptionState.IN_GRACE_PERIOD, + "in-billing-retry": SubscriptionState.IN_BILLING_RETRY, + "expired": SubscriptionState.EXPIRED, + "revoked": SubscriptionState.REVOKED, + "refunded": SubscriptionState.REFUNDED, + "paused": SubscriptionState.PAUSED, + "unknown": SubscriptionState.UNKNOWN +} + +const WEBHOOK_CANCELLATION_REASON_FROM_STRING = { + "user-canceled": WebhookCancellationReason.USER_CANCELED, + "billing-error": WebhookCancellationReason.BILLING_ERROR, + "price-increase-declined": WebhookCancellationReason.PRICE_INCREASE_DECLINED, + "product-unavailable": WebhookCancellationReason.PRODUCT_UNAVAILABLE, + "refunded": WebhookCancellationReason.REFUNDED, + "other": WebhookCancellationReason.OTHER +} + +const WEBHOOK_EVENT_ENVIRONMENT_FROM_STRING = { + "production": WebhookEventEnvironment.PRODUCTION, + "sandbox": WebhookEventEnvironment.SANDBOX, + "xcode": WebhookEventEnvironment.XCODE +} + +const WEBHOOK_EVENT_SOURCE_FROM_STRING = { + "apple-app-store-server-notifications-v2": WebhookEventSource.APPLE_APP_STORE_SERVER_NOTIFICATIONS_V2, + "google-play-real-time-developer-notifications": WebhookEventSource.GOOGLE_PLAY_REAL_TIME_DEVELOPER_NOTIFICATIONS +} + +const WEBHOOK_EVENT_TYPE_FROM_STRING = { + "subscription-started": WebhookEventType.SUBSCRIPTION_STARTED, + "subscription-renewed": WebhookEventType.SUBSCRIPTION_RENEWED, + "subscription-expired": WebhookEventType.SUBSCRIPTION_EXPIRED, + "subscription-in-grace-period": WebhookEventType.SUBSCRIPTION_IN_GRACE_PERIOD, + "subscription-in-billing-retry": WebhookEventType.SUBSCRIPTION_IN_BILLING_RETRY, + "subscription-recovered": WebhookEventType.SUBSCRIPTION_RECOVERED, + "subscription-canceled": WebhookEventType.SUBSCRIPTION_CANCELED, + "subscription-uncanceled": WebhookEventType.SUBSCRIPTION_UNCANCELED, + "subscription-revoked": WebhookEventType.SUBSCRIPTION_REVOKED, + "subscription-price-change": WebhookEventType.SUBSCRIPTION_PRICE_CHANGE, + "subscription-product-changed": WebhookEventType.SUBSCRIPTION_PRODUCT_CHANGED, + "subscription-paused": WebhookEventType.SUBSCRIPTION_PAUSED, + "subscription-resumed": WebhookEventType.SUBSCRIPTION_RESUMED, + "purchase-refunded": WebhookEventType.PURCHASE_REFUNDED, + "purchase-consumption-request": WebhookEventType.PURCHASE_CONSUMPTION_REQUEST, + "test-notification": WebhookEventType.TEST_NOTIFICATION +} + # ============================================================================ # Query Types # ============================================================================ @@ -5129,6 +5434,30 @@ class Query: const return_type = "VerifyPurchaseResultIOS" const is_array = false + ## Replay missed webhook events for the authenticated client since the given + class webhookEventsSinceField: + const name = "webhookEventsSince" + const snake_name = "webhook_events_since" + class Args: + var since_ms: float + var limit: int + + static func from_dict(data: Dictionary) -> Args: + var obj = Args.new() + if data.has("sinceMs") and data["sinceMs"] != null: + obj.since_ms = data["sinceMs"] + if data.has("limit") and data["limit"] != null: + obj.limit = data["limit"] + return obj + + func to_dict() -> Dictionary: + var dict = {} + dict["sinceMs"] = since_ms + dict["limit"] = limit + return dict + const return_type = "WebhookEvent" + const is_array = true + # ============================================================================ # Mutation Types @@ -5696,6 +6025,13 @@ static func validate_receipt_ios_args(options: VerifyPurchaseProps) -> Dictionar args["options"] = options return args +## Replay missed webhook events for the authenticated client since the given +static func webhook_events_since_args(since_ms: float, limit: int) -> Dictionary: + var args = {} + args["sinceMs"] = since_ms + args["limit"] = limit + return args + # Mutation API helpers ## Initialize the store connection. Call before any IAP API. diff --git a/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt b/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt index 2c749a69..a01bb837 100644 --- a/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt +++ b/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt @@ -1053,6 +1053,279 @@ public enum class SubscriptionReplacementModeAndroid(val rawValue: String) { fun toJson(): String = rawValue } +public enum class SubscriptionState(val rawValue: String) { + Active("active"), + InGracePeriod("in-grace-period"), + InBillingRetry("in-billing-retry"), + Expired("expired"), + Revoked("revoked"), + Refunded("refunded"), + Paused("paused"), + Unknown("unknown"); + + companion object { + fun fromJson(value: String): SubscriptionState = when (value) { + "active" -> SubscriptionState.Active + "ACTIVE" -> SubscriptionState.Active + "Active" -> SubscriptionState.Active + "in-grace-period" -> SubscriptionState.InGracePeriod + "IN_GRACE_PERIOD" -> SubscriptionState.InGracePeriod + "InGracePeriod" -> SubscriptionState.InGracePeriod + "in-billing-retry" -> SubscriptionState.InBillingRetry + "IN_BILLING_RETRY" -> SubscriptionState.InBillingRetry + "InBillingRetry" -> SubscriptionState.InBillingRetry + "expired" -> SubscriptionState.Expired + "EXPIRED" -> SubscriptionState.Expired + "Expired" -> SubscriptionState.Expired + "revoked" -> SubscriptionState.Revoked + "REVOKED" -> SubscriptionState.Revoked + "Revoked" -> SubscriptionState.Revoked + "refunded" -> SubscriptionState.Refunded + "REFUNDED" -> SubscriptionState.Refunded + "Refunded" -> SubscriptionState.Refunded + "paused" -> SubscriptionState.Paused + "PAUSED" -> SubscriptionState.Paused + "Paused" -> SubscriptionState.Paused + "unknown" -> SubscriptionState.Unknown + "UNKNOWN" -> SubscriptionState.Unknown + "Unknown" -> SubscriptionState.Unknown + else -> throw IllegalArgumentException("Unknown SubscriptionState value: $value") + } + } + + fun toJson(): String = rawValue +} + +public enum class WebhookCancellationReason(val rawValue: String) { + UserCanceled("user-canceled"), + BillingError("billing-error"), + PriceIncreaseDeclined("price-increase-declined"), + ProductUnavailable("product-unavailable"), + Refunded("refunded"), + Other("other"); + + companion object { + fun fromJson(value: String): WebhookCancellationReason = when (value) { + "user-canceled" -> WebhookCancellationReason.UserCanceled + "USER_CANCELED" -> WebhookCancellationReason.UserCanceled + "UserCanceled" -> WebhookCancellationReason.UserCanceled + "billing-error" -> WebhookCancellationReason.BillingError + "BILLING_ERROR" -> WebhookCancellationReason.BillingError + "BillingError" -> WebhookCancellationReason.BillingError + "price-increase-declined" -> WebhookCancellationReason.PriceIncreaseDeclined + "PRICE_INCREASE_DECLINED" -> WebhookCancellationReason.PriceIncreaseDeclined + "PriceIncreaseDeclined" -> WebhookCancellationReason.PriceIncreaseDeclined + "product-unavailable" -> WebhookCancellationReason.ProductUnavailable + "PRODUCT_UNAVAILABLE" -> WebhookCancellationReason.ProductUnavailable + "ProductUnavailable" -> WebhookCancellationReason.ProductUnavailable + "refunded" -> WebhookCancellationReason.Refunded + "REFUNDED" -> WebhookCancellationReason.Refunded + "Refunded" -> WebhookCancellationReason.Refunded + "other" -> WebhookCancellationReason.Other + "OTHER" -> WebhookCancellationReason.Other + "Other" -> WebhookCancellationReason.Other + else -> throw IllegalArgumentException("Unknown WebhookCancellationReason value: $value") + } + } + + fun toJson(): String = rawValue +} + +public enum class WebhookEventEnvironment(val rawValue: String) { + Production("production"), + Sandbox("sandbox"), + Xcode("xcode"); + + companion object { + fun fromJson(value: String): WebhookEventEnvironment = when (value) { + "production" -> WebhookEventEnvironment.Production + "PRODUCTION" -> WebhookEventEnvironment.Production + "Production" -> WebhookEventEnvironment.Production + "sandbox" -> WebhookEventEnvironment.Sandbox + "SANDBOX" -> WebhookEventEnvironment.Sandbox + "Sandbox" -> WebhookEventEnvironment.Sandbox + "xcode" -> WebhookEventEnvironment.Xcode + "XCODE" -> WebhookEventEnvironment.Xcode + "Xcode" -> WebhookEventEnvironment.Xcode + else -> throw IllegalArgumentException("Unknown WebhookEventEnvironment value: $value") + } + } + + fun toJson(): String = rawValue +} + +public enum class WebhookEventSource(val rawValue: String) { + AppleAppStoreServerNotificationsV2("apple-app-store-server-notifications-v2"), + GooglePlayRealTimeDeveloperNotifications("google-play-real-time-developer-notifications"); + + companion object { + fun fromJson(value: String): WebhookEventSource = when (value) { + "apple-app-store-server-notifications-v2" -> WebhookEventSource.AppleAppStoreServerNotificationsV2 + "APPLE_APP_STORE_SERVER_NOTIFICATIONS_V2" -> WebhookEventSource.AppleAppStoreServerNotificationsV2 + "AppleAppStoreServerNotificationsV2" -> WebhookEventSource.AppleAppStoreServerNotificationsV2 + "google-play-real-time-developer-notifications" -> WebhookEventSource.GooglePlayRealTimeDeveloperNotifications + "GOOGLE_PLAY_REAL_TIME_DEVELOPER_NOTIFICATIONS" -> WebhookEventSource.GooglePlayRealTimeDeveloperNotifications + "GooglePlayRealTimeDeveloperNotifications" -> WebhookEventSource.GooglePlayRealTimeDeveloperNotifications + else -> throw IllegalArgumentException("Unknown WebhookEventSource value: $value") + } + } + + fun toJson(): String = rawValue +} + +public enum class WebhookEventType(val rawValue: String) { + /** + * Initial purchase or first conversion from a free trial / intro offer. + * iOS: SUBSCRIBED (initialBuy / resubscribe). + * Android: SUBSCRIPTION_PURCHASED. + */ + SubscriptionStarted("subscription-started"), + /** + * Auto-renewal succeeded for an existing subscription. + * iOS: DID_RENEW. + * Android: SUBSCRIPTION_RENEWED. + */ + SubscriptionRenewed("subscription-renewed"), + /** + * Subscription reached its expiration without a successful renewal. + * iOS: EXPIRED. + * Android: SUBSCRIPTION_EXPIRED. + */ + SubscriptionExpired("subscription-expired"), + /** + * Billing failed; the subscription is in a grace period during which the user + * retains entitlement while payment is retried. + * iOS: DID_FAIL_TO_RENEW (with grace period active). + * Android: SUBSCRIPTION_IN_GRACE_PERIOD. + */ + SubscriptionInGracePeriod("subscription-in-grace-period"), + /** + * Billing failed and the subscription is in account-hold / billing retry, + * during which entitlement is paused but the subscription is not yet expired. + * iOS: DID_FAIL_TO_RENEW (no grace period; billing retry). + * Android: SUBSCRIPTION_ON_HOLD. + */ + SubscriptionInBillingRetry("subscription-in-billing-retry"), + /** + * 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"), + /** + * Access immediately revoked (family sharing removal, admin action, fraud). + * iOS: REVOKE. + * Android: SUBSCRIPTION_REVOKED. + */ + SubscriptionRevoked("subscription-revoked"), + /** + * A price change is pending or has been confirmed by the user. + * iOS: PRICE_INCREASE. + * Android: SUBSCRIPTION_PRICE_CHANGE_CONFIRMED. + */ + SubscriptionPriceChange("subscription-price-change"), + /** + * User upgraded, downgraded, or crossgraded their plan. + * iOS: DID_CHANGE_RENEWAL_PREF. + * Android: SUBSCRIPTION_DEFERRED / SUBSCRIPTION_PRODUCT_CHANGED. + */ + SubscriptionProductChanged("subscription-product-changed"), + /** + * Subscription paused (Android only feature). + * Android: SUBSCRIPTION_PAUSED. + */ + SubscriptionPaused("subscription-paused"), + /** + * Paused subscription resumed (Android only feature). + * Android: SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED / SUBSCRIPTION_RECOVERED from pause. + */ + SubscriptionResumed("subscription-resumed"), + /** + * Refund issued for a one-time purchase or subscription period. + * iOS: REFUND. + * Android: ONE_TIME_PRODUCT_REFUNDED / VOIDED_PURCHASE. + */ + PurchaseRefunded("purchase-refunded"), + /** + * iOS-only: App Store requests a consumption status report for a refund decision. + * Servers should respond via the StoreKit consumption API. + */ + PurchaseConsumptionRequest("purchase-consumption-request"), + /** + * Sandbox or test notification fired by the store for diagnostic purposes. + * Useful for verifying webhook plumbing without a live transaction. + */ + TestNotification("test-notification"); + + companion object { + fun fromJson(value: String): WebhookEventType = when (value) { + "subscription-started" -> WebhookEventType.SubscriptionStarted + "SUBSCRIPTION_STARTED" -> WebhookEventType.SubscriptionStarted + "SubscriptionStarted" -> WebhookEventType.SubscriptionStarted + "subscription-renewed" -> WebhookEventType.SubscriptionRenewed + "SUBSCRIPTION_RENEWED" -> WebhookEventType.SubscriptionRenewed + "SubscriptionRenewed" -> WebhookEventType.SubscriptionRenewed + "subscription-expired" -> WebhookEventType.SubscriptionExpired + "SUBSCRIPTION_EXPIRED" -> WebhookEventType.SubscriptionExpired + "SubscriptionExpired" -> WebhookEventType.SubscriptionExpired + "subscription-in-grace-period" -> WebhookEventType.SubscriptionInGracePeriod + "SUBSCRIPTION_IN_GRACE_PERIOD" -> WebhookEventType.SubscriptionInGracePeriod + "SubscriptionInGracePeriod" -> WebhookEventType.SubscriptionInGracePeriod + "subscription-in-billing-retry" -> WebhookEventType.SubscriptionInBillingRetry + "SUBSCRIPTION_IN_BILLING_RETRY" -> WebhookEventType.SubscriptionInBillingRetry + "SubscriptionInBillingRetry" -> WebhookEventType.SubscriptionInBillingRetry + "subscription-recovered" -> WebhookEventType.SubscriptionRecovered + "SUBSCRIPTION_RECOVERED" -> WebhookEventType.SubscriptionRecovered + "SubscriptionRecovered" -> WebhookEventType.SubscriptionRecovered + "subscription-canceled" -> WebhookEventType.SubscriptionCanceled + "SUBSCRIPTION_CANCELED" -> WebhookEventType.SubscriptionCanceled + "SubscriptionCanceled" -> WebhookEventType.SubscriptionCanceled + "subscription-uncanceled" -> WebhookEventType.SubscriptionUncanceled + "SUBSCRIPTION_UNCANCELED" -> WebhookEventType.SubscriptionUncanceled + "SubscriptionUncanceled" -> WebhookEventType.SubscriptionUncanceled + "subscription-revoked" -> WebhookEventType.SubscriptionRevoked + "SUBSCRIPTION_REVOKED" -> WebhookEventType.SubscriptionRevoked + "SubscriptionRevoked" -> WebhookEventType.SubscriptionRevoked + "subscription-price-change" -> WebhookEventType.SubscriptionPriceChange + "SUBSCRIPTION_PRICE_CHANGE" -> WebhookEventType.SubscriptionPriceChange + "SubscriptionPriceChange" -> WebhookEventType.SubscriptionPriceChange + "subscription-product-changed" -> WebhookEventType.SubscriptionProductChanged + "SUBSCRIPTION_PRODUCT_CHANGED" -> WebhookEventType.SubscriptionProductChanged + "SubscriptionProductChanged" -> WebhookEventType.SubscriptionProductChanged + "subscription-paused" -> WebhookEventType.SubscriptionPaused + "SUBSCRIPTION_PAUSED" -> WebhookEventType.SubscriptionPaused + "SubscriptionPaused" -> WebhookEventType.SubscriptionPaused + "subscription-resumed" -> WebhookEventType.SubscriptionResumed + "SUBSCRIPTION_RESUMED" -> WebhookEventType.SubscriptionResumed + "SubscriptionResumed" -> WebhookEventType.SubscriptionResumed + "purchase-refunded" -> WebhookEventType.PurchaseRefunded + "PURCHASE_REFUNDED" -> WebhookEventType.PurchaseRefunded + "PurchaseRefunded" -> WebhookEventType.PurchaseRefunded + "purchase-consumption-request" -> WebhookEventType.PurchaseConsumptionRequest + "PURCHASE_CONSUMPTION_REQUEST" -> WebhookEventType.PurchaseConsumptionRequest + "PurchaseConsumptionRequest" -> WebhookEventType.PurchaseConsumptionRequest + "test-notification" -> WebhookEventType.TestNotification + "TEST_NOTIFICATION" -> WebhookEventType.TestNotification + "TestNotification" -> WebhookEventType.TestNotification + else -> throw IllegalArgumentException("Unknown WebhookEventType value: $value") + } + } + + fun toJson(): String = rawValue +} + // MARK: - Interfaces public interface ProductCommon { @@ -3678,6 +3951,119 @@ public data class VerifyPurchaseWithProviderResult( public typealias VoidResult = Unit +public data class WebhookEvent( + /** + * Reason for cancellation, when applicable. + */ + val cancellationReason: WebhookCancellationReason? = null, + /** + * Localized currency code (ISO 4217) at event time, when available. + */ + val currency: String? = null, + val environment: WebhookEventEnvironment, + /** + * When the current subscription period ends. Epoch milliseconds. + */ + val expiresAt: Double? = null, + /** + * 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. + */ + val id: String, + /** + * Time the underlying event occurred at the store. Epoch milliseconds. + */ + val occurredAt: Double, + val 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. + */ + val priceAmountMicros: Double? = null, + /** + * Product the event pertains to. May be null for account-level events. + */ + val productId: String? = null, + /** + * kit project that owns the subscription / purchase this event refers to. + */ + val projectId: String, + /** + * Cross-platform purchase identity used to correlate this event with an existing + * purchase record. iOS: `originalTransactionId`. Android: `purchaseToken`. + */ + val purchaseToken: String, + /** + * Original signed payload from the store. ASN v2 events expose the JWS string; + * RTDN events expose the base64-decoded Pub/Sub message JSON. Provided so that + * consumers can independently verify or extract platform-specific fields. kit + * always validates this payload before emitting the event. + */ + val rawSignedPayload: String? = null, + /** + * Time kit ingested and normalized this event. Epoch milliseconds. + */ + val receivedAt: Double, + /** + * When auto-renewal will charge again. Epoch milliseconds. + */ + val renewsAt: Double? = null, + val source: WebhookEventSource, + /** + * Normalized subscription state at the time of event, when the event refers to + * a subscription. Null for one-time purchase events. + */ + val subscriptionState: SubscriptionState? = null, + val type: WebhookEventType +) { + + companion object { + fun fromJson(json: Map): 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, + ) + } + } + + fun toJson(): Map = mapOf( + "__typename" to "WebhookEvent", + "cancellationReason" to cancellationReason?.toJson(), + "currency" to currency, + "environment" to environment.toJson(), + "expiresAt" to expiresAt, + "id" to id, + "occurredAt" to occurredAt, + "platform" to platform.toJson(), + "priceAmountMicros" to priceAmountMicros, + "productId" to productId, + "projectId" to projectId, + "purchaseToken" to purchaseToken, + "rawSignedPayload" to rawSignedPayload, + "receivedAt" to receivedAt, + "renewsAt" to renewsAt, + "source" to source.toJson(), + "subscriptionState" to subscriptionState?.toJson(), + "type" to type.toJson(), + ) +} + // MARK: - Input Objects public data class AndroidSubscriptionOfferInput( @@ -5122,6 +5508,12 @@ public interface QueryResolver { * See: https://www.openiap.dev/docs/apis/ios/validate-receipt-ios */ suspend fun validateReceiptIOS(options: VerifyPurchaseProps): VerifyPurchaseResultIOS + /** + * 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. + */ + suspend fun webhookEventsSince(sinceMs: Double, limit: Int? = null): List } /** @@ -5167,6 +5559,16 @@ public interface SubscriptionResolver { * Only triggered when the user selects alternative billing instead of Google Play billing */ suspend fun userChoiceBillingAndroid(): UserChoiceBillingDetails + /** + * Streams normalized webhook events tied to the authenticated client's purchases. + * Clients only receive events whose `purchaseToken` matches a purchase they own. + * + * Transport: kit serves this over WebSocket. SDKs auto-connect when the host app + * enters foreground and disconnect when it goes to background. Events that fire + * while the connection is closed are reconciled via `webhookEventsSince` on + * reconnect or the next foreground entry. + */ + suspend fun webhookEvent(): WebhookEvent } // MARK: - Root Operation Helpers @@ -5252,6 +5654,7 @@ public typealias QueryIsTransactionVerifiedIOSHandler = suspend (sku: String) -> public typealias QueryLatestTransactionIOSHandler = suspend (sku: String) -> PurchaseIOS? public typealias QuerySubscriptionStatusIOSHandler = suspend (sku: String) -> List public typealias QueryValidateReceiptIOSHandler = suspend (options: VerifyPurchaseProps) -> VerifyPurchaseResultIOS +public typealias QueryWebhookEventsSinceHandler = suspend (sinceMs: Double, limit: Int?) -> List public data class QueryHandlers( val canPresentExternalPurchaseNoticeIOS: QueryCanPresentExternalPurchaseNoticeIOSHandler? = null, @@ -5274,7 +5677,8 @@ public data class QueryHandlers( val isTransactionVerifiedIOS: QueryIsTransactionVerifiedIOSHandler? = null, val latestTransactionIOS: QueryLatestTransactionIOSHandler? = null, val subscriptionStatusIOS: QuerySubscriptionStatusIOSHandler? = null, - val validateReceiptIOS: QueryValidateReceiptIOSHandler? = null + val validateReceiptIOS: QueryValidateReceiptIOSHandler? = null, + val webhookEventsSince: QueryWebhookEventsSinceHandler? = null ) // MARK: - Subscription Helpers @@ -5285,6 +5689,7 @@ public typealias SubscriptionPurchaseErrorHandler = suspend () -> PurchaseError public typealias SubscriptionPurchaseUpdatedHandler = suspend () -> Purchase public typealias SubscriptionSubscriptionBillingIssueHandler = suspend () -> Purchase public typealias SubscriptionUserChoiceBillingAndroidHandler = suspend () -> UserChoiceBillingDetails +public typealias SubscriptionWebhookEventHandler = suspend () -> WebhookEvent public data class SubscriptionHandlers( val developerProvidedBillingAndroid: SubscriptionDeveloperProvidedBillingAndroidHandler? = null, @@ -5292,5 +5697,6 @@ public data class SubscriptionHandlers( val purchaseError: SubscriptionPurchaseErrorHandler? = null, val purchaseUpdated: SubscriptionPurchaseUpdatedHandler? = null, val subscriptionBillingIssue: SubscriptionSubscriptionBillingIssueHandler? = null, - val userChoiceBillingAndroid: SubscriptionUserChoiceBillingAndroidHandler? = null + val userChoiceBillingAndroid: SubscriptionUserChoiceBillingAndroidHandler? = null, + val webhookEvent: SubscriptionWebhookEventHandler? = null ) diff --git a/libraries/react-native-iap/src/types.ts b/libraries/react-native-iap/src/types.ts index 48f0103b..cc3408f3 100644 --- a/libraries/react-native-iap/src/types.ts +++ b/libraries/react-native-iap/src/types.ts @@ -1406,6 +1406,12 @@ export interface Query { * @deprecated Use verifyPurchase */ validateReceiptIOS: Promise; + /** + * 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[]; } @@ -1434,6 +1440,11 @@ export type QuerySubscriptionStatusIosArgs = string; export type QueryValidateReceiptIosArgs = VerifyPurchaseProps; +export interface QueryWebhookEventsSinceArgs { + limit?: (number | null); + sinceMs: number; +} + export interface RefundResultIOS { message?: (string | null); status: string; @@ -1751,6 +1762,16 @@ export interface Subscription { * Only triggered when the user selects alternative billing instead of Google Play billing */ userChoiceBillingAndroid: UserChoiceBillingDetails; + /** + * Streams normalized webhook events tied to the authenticated client's purchases. + * Clients only receive events whose `purchaseToken` matches a purchase they own. + * + * Transport: kit serves this over WebSocket. SDKs auto-connect when the host app + * enters foreground and disconnect when it goes to background. Events that fire + * while the connection is closed are reconciled via `webhookEventsSince` on + * reconnect or the next foreground entry. + */ + webhookEvent: WebhookEvent; } @@ -1896,6 +1917,8 @@ export interface SubscriptionProductReplacementParamsAndroid { */ export type SubscriptionReplacementModeAndroid = 'unknown-replacement-mode' | 'with-time-proration' | 'charge-prorated-price' | 'charge-full-price' | 'without-proration' | 'deferred' | 'keep-existing'; +export type SubscriptionState = 'active' | 'expired' | 'in-billing-retry' | 'in-grace-period' | 'paused' | 'refunded' | 'revoked' | 'unknown'; + export interface SubscriptionStatusIOS { renewalInfo?: (RenewalInfoIOS | null); state: string; @@ -2057,6 +2080,65 @@ export interface VerifyPurchaseWithProviderResult { export type VoidResult = void; +export type WebhookCancellationReason = 'billing-error' | 'other' | 'price-increase-declined' | 'product-unavailable' | 'refunded' | 'user-canceled'; + +export interface WebhookEvent { + /** Reason for cancellation, when applicable. */ + cancellationReason?: (WebhookCancellationReason | null); + /** Localized currency code (ISO 4217) at event time, when available. */ + currency?: (string | null); + environment: WebhookEventEnvironment; + /** When the current subscription period ends. Epoch milliseconds. */ + expiresAt?: (number | null); + /** + * 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. + */ + id: string; + /** Time the underlying event occurred at the store. Epoch milliseconds. */ + occurredAt: number; + 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. + */ + priceAmountMicros?: (number | null); + /** Product the event pertains to. May be null for account-level events. */ + productId?: (string | null); + /** kit project that owns the subscription / purchase this event refers to. */ + projectId: string; + /** + * Cross-platform purchase identity used to correlate this event with an existing + * purchase record. iOS: `originalTransactionId`. Android: `purchaseToken`. + */ + purchaseToken: string; + /** + * Original signed payload from the store. ASN v2 events expose the JWS string; + * RTDN events expose the base64-decoded Pub/Sub message JSON. Provided so that + * consumers can independently verify or extract platform-specific fields. kit + * always validates this payload before emitting the event. + */ + rawSignedPayload?: (string | null); + /** Time kit ingested and normalized this event. Epoch milliseconds. */ + receivedAt: number; + /** When auto-renewal will charge again. Epoch milliseconds. */ + renewsAt?: (number | null); + source: WebhookEventSource; + /** + * Normalized subscription state at the time of event, when the event refers to + * a subscription. Null for one-time purchase events. + */ + subscriptionState?: (SubscriptionState | null); + type: WebhookEventType; +} + +export type WebhookEventEnvironment = 'production' | 'sandbox' | 'xcode'; + +export type WebhookEventSource = 'apple-app-store-server-notifications-v2' | 'google-play-real-time-developer-notifications'; + +export type WebhookEventType = 'purchase-consumption-request' | 'purchase-refunded' | 'subscription-canceled' | 'subscription-expired' | 'subscription-in-billing-retry' | 'subscription-in-grace-period' | 'subscription-paused' | 'subscription-price-change' | 'subscription-product-changed' | 'subscription-recovered' | 'subscription-renewed' | 'subscription-resumed' | 'subscription-revoked' | 'subscription-started' | 'subscription-uncanceled' | 'test-notification'; + /** * Win-back offer input for iOS 18+ (StoreKit 2) * Win-back offers are used to re-engage churned subscribers. @@ -2090,6 +2172,7 @@ export type QueryArgsMap = { latestTransactionIOS: QueryLatestTransactionIosArgs; subscriptionStatusIOS: QuerySubscriptionStatusIosArgs; validateReceiptIOS: QueryValidateReceiptIosArgs; + webhookEventsSince: QueryWebhookEventsSinceArgs; }; export type QueryField = @@ -2154,6 +2237,7 @@ export type SubscriptionArgsMap = { purchaseUpdated: never; subscriptionBillingIssue: never; userChoiceBillingAndroid: never; + webhookEvent: never; }; export type SubscriptionField = diff --git a/packages/apple/Sources/Models/Types.swift b/packages/apple/Sources/Models/Types.swift index efacb85c..39962124 100644 --- a/packages/apple/Sources/Models/Types.swift +++ b/packages/apple/Sources/Models/Types.swift @@ -424,6 +424,102 @@ public enum SubscriptionReplacementModeAndroid: String, Codable, CaseIterable { case keepExisting = "keep-existing" } +public enum SubscriptionState: String, Codable, CaseIterable { + case active = "active" + case inGracePeriod = "in-grace-period" + case inBillingRetry = "in-billing-retry" + case expired = "expired" + case revoked = "revoked" + case refunded = "refunded" + case paused = "paused" + case unknown = "unknown" +} + +public enum WebhookCancellationReason: String, Codable, CaseIterable { + case userCanceled = "user-canceled" + case billingError = "billing-error" + case priceIncreaseDeclined = "price-increase-declined" + case productUnavailable = "product-unavailable" + case refunded = "refunded" + case other = "other" +} + +public enum WebhookEventEnvironment: String, Codable, CaseIterable { + case production = "production" + case sandbox = "sandbox" + case xcode = "xcode" +} + +public enum WebhookEventSource: String, Codable, CaseIterable { + case appleAppStoreServerNotificationsV2 = "apple-app-store-server-notifications-v2" + case googlePlayRealTimeDeveloperNotifications = "google-play-real-time-developer-notifications" +} + +public enum WebhookEventType: String, Codable, CaseIterable { + /// Initial purchase or first conversion from a free trial / intro offer. + /// iOS: SUBSCRIBED (initialBuy / resubscribe). + /// Android: SUBSCRIPTION_PURCHASED. + case subscriptionStarted = "subscription-started" + /// Auto-renewal succeeded for an existing subscription. + /// iOS: DID_RENEW. + /// Android: SUBSCRIPTION_RENEWED. + case subscriptionRenewed = "subscription-renewed" + /// Subscription reached its expiration without a successful renewal. + /// iOS: EXPIRED. + /// Android: SUBSCRIPTION_EXPIRED. + case subscriptionExpired = "subscription-expired" + /// Billing failed; the subscription is in a grace period during which the user + /// retains entitlement while payment is retried. + /// iOS: DID_FAIL_TO_RENEW (with grace period active). + /// Android: SUBSCRIPTION_IN_GRACE_PERIOD. + case subscriptionInGracePeriod = "subscription-in-grace-period" + /// Billing failed and the subscription is in account-hold / billing retry, + /// during which entitlement is paused but the subscription is not yet expired. + /// iOS: DID_FAIL_TO_RENEW (no grace period; billing retry). + /// Android: SUBSCRIPTION_ON_HOLD. + case subscriptionInBillingRetry = "subscription-in-billing-retry" + /// Subscription returned to active state after a billing issue or pause. + /// iOS: DID_RECOVER. + /// Android: SUBSCRIPTION_RECOVERED / SUBSCRIPTION_RESTARTED. + case 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. + case 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). + case subscriptionUncanceled = "subscription-uncanceled" + /// Access immediately revoked (family sharing removal, admin action, fraud). + /// iOS: REVOKE. + /// Android: SUBSCRIPTION_REVOKED. + case subscriptionRevoked = "subscription-revoked" + /// A price change is pending or has been confirmed by the user. + /// iOS: PRICE_INCREASE. + /// Android: SUBSCRIPTION_PRICE_CHANGE_CONFIRMED. + case subscriptionPriceChange = "subscription-price-change" + /// User upgraded, downgraded, or crossgraded their plan. + /// iOS: DID_CHANGE_RENEWAL_PREF. + /// Android: SUBSCRIPTION_DEFERRED / SUBSCRIPTION_PRODUCT_CHANGED. + case subscriptionProductChanged = "subscription-product-changed" + /// Subscription paused (Android only feature). + /// Android: SUBSCRIPTION_PAUSED. + case subscriptionPaused = "subscription-paused" + /// Paused subscription resumed (Android only feature). + /// Android: SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED / SUBSCRIPTION_RECOVERED from pause. + case subscriptionResumed = "subscription-resumed" + /// Refund issued for a one-time purchase or subscription period. + /// iOS: REFUND. + /// Android: ONE_TIME_PRODUCT_REFUNDED / VOIDED_PURCHASE. + case purchaseRefunded = "purchase-refunded" + /// iOS-only: App Store requests a consumption status report for a refund decision. + /// Servers should respond via the StoreKit consumption API. + case purchaseConsumptionRequest = "purchase-consumption-request" + /// Sandbox or test notification fired by the store for diagnostic purposes. + /// Useful for verifying webhook plumbing without a live transaction. + case testNotification = "test-notification" +} + // MARK: - Interfaces public protocol ProductCommon: Codable { @@ -1320,6 +1416,47 @@ public struct VerifyPurchaseWithProviderResult: Codable { public typealias VoidResult = Void +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; + /// RTDN events expose the base64-decoded Pub/Sub message JSON. Provided so that + /// consumers can independently verify or extract platform-specific fields. kit + /// always validates this payload before emitting the event. + public var rawSignedPayload: String? = nil + /// Time kit ingested and normalized this event. Epoch milliseconds. + public var receivedAt: Double + /// When auto-renewal will charge again. Epoch milliseconds. + public var renewsAt: Double? = nil + public var source: WebhookEventSource + /// Normalized subscription state at the time of event, when the event refers to + /// a subscription. Null for one-time purchase events. + public var subscriptionState: SubscriptionState? = nil + public var type: WebhookEventType +} + // MARK: - Input Objects public struct AndroidSubscriptionOfferInput: Codable { @@ -2526,6 +2663,10 @@ public protocol QueryResolver { /// Deprecated. Legacy App Store receipt validation — use verifyPurchase instead. /// See: https://www.openiap.dev/docs/apis/ios/validate-receipt-ios func validateReceiptIOS(_ options: VerifyPurchaseProps) async throws -> VerifyPurchaseResultIOS + /// 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. + func webhookEventsSince(sinceMs: Double, limit: Int?) async throws -> [WebhookEvent] } /// GraphQL root subscription operations. @@ -2557,6 +2698,14 @@ public protocol SubscriptionResolver { /// Fires when a user selects alternative billing in the User Choice Billing dialog (Android only) /// Only triggered when the user selects alternative billing instead of Google Play billing func userChoiceBillingAndroid() async throws -> UserChoiceBillingDetails + /// Streams normalized webhook events tied to the authenticated client's purchases. + /// Clients only receive events whose `purchaseToken` matches a purchase they own. + /// + /// Transport: kit serves this over WebSocket. SDKs auto-connect when the host app + /// enters foreground and disconnect when it goes to background. Events that fire + /// while the connection is closed are reconciled via `webhookEventsSince` on + /// reconnect or the next foreground entry. + func webhookEvent() async throws -> WebhookEvent } // MARK: - Root Operation Helpers @@ -2698,6 +2847,7 @@ public typealias QueryIsTransactionVerifiedIOSHandler = (_ sku: String) async th public typealias QueryLatestTransactionIOSHandler = (_ sku: String) async throws -> PurchaseIOS? public typealias QuerySubscriptionStatusIOSHandler = (_ sku: String) async throws -> [SubscriptionStatusIOS] public typealias QueryValidateReceiptIOSHandler = (_ options: VerifyPurchaseProps) async throws -> VerifyPurchaseResultIOS +public typealias QueryWebhookEventsSinceHandler = (_ sinceMs: Double, _ limit: Int?) async throws -> [WebhookEvent] public struct QueryHandlers { public var canPresentExternalPurchaseNoticeIOS: QueryCanPresentExternalPurchaseNoticeIOSHandler? @@ -2721,6 +2871,7 @@ public struct QueryHandlers { public var latestTransactionIOS: QueryLatestTransactionIOSHandler? public var subscriptionStatusIOS: QuerySubscriptionStatusIOSHandler? public var validateReceiptIOS: QueryValidateReceiptIOSHandler? + public var webhookEventsSince: QueryWebhookEventsSinceHandler? public init( canPresentExternalPurchaseNoticeIOS: QueryCanPresentExternalPurchaseNoticeIOSHandler? = nil, @@ -2743,7 +2894,8 @@ public struct QueryHandlers { isTransactionVerifiedIOS: QueryIsTransactionVerifiedIOSHandler? = nil, latestTransactionIOS: QueryLatestTransactionIOSHandler? = nil, subscriptionStatusIOS: QuerySubscriptionStatusIOSHandler? = nil, - validateReceiptIOS: QueryValidateReceiptIOSHandler? = nil + validateReceiptIOS: QueryValidateReceiptIOSHandler? = nil, + webhookEventsSince: QueryWebhookEventsSinceHandler? = nil ) { self.canPresentExternalPurchaseNoticeIOS = canPresentExternalPurchaseNoticeIOS self.currentEntitlementIOS = currentEntitlementIOS @@ -2766,6 +2918,7 @@ public struct QueryHandlers { self.latestTransactionIOS = latestTransactionIOS self.subscriptionStatusIOS = subscriptionStatusIOS self.validateReceiptIOS = validateReceiptIOS + self.webhookEventsSince = webhookEventsSince } } @@ -2777,6 +2930,7 @@ public typealias SubscriptionPurchaseErrorHandler = () async throws -> PurchaseE public typealias SubscriptionPurchaseUpdatedHandler = () async throws -> Purchase public typealias SubscriptionSubscriptionBillingIssueHandler = () async throws -> Purchase public typealias SubscriptionUserChoiceBillingAndroidHandler = () async throws -> UserChoiceBillingDetails +public typealias SubscriptionWebhookEventHandler = () async throws -> WebhookEvent public struct SubscriptionHandlers { public var developerProvidedBillingAndroid: SubscriptionDeveloperProvidedBillingAndroidHandler? @@ -2785,6 +2939,7 @@ public struct SubscriptionHandlers { public var purchaseUpdated: SubscriptionPurchaseUpdatedHandler? public var subscriptionBillingIssue: SubscriptionSubscriptionBillingIssueHandler? public var userChoiceBillingAndroid: SubscriptionUserChoiceBillingAndroidHandler? + public var webhookEvent: SubscriptionWebhookEventHandler? public init( developerProvidedBillingAndroid: SubscriptionDeveloperProvidedBillingAndroidHandler? = nil, @@ -2792,7 +2947,8 @@ public struct SubscriptionHandlers { purchaseError: SubscriptionPurchaseErrorHandler? = nil, purchaseUpdated: SubscriptionPurchaseUpdatedHandler? = nil, subscriptionBillingIssue: SubscriptionSubscriptionBillingIssueHandler? = nil, - userChoiceBillingAndroid: SubscriptionUserChoiceBillingAndroidHandler? = nil + userChoiceBillingAndroid: SubscriptionUserChoiceBillingAndroidHandler? = nil, + webhookEvent: SubscriptionWebhookEventHandler? = nil ) { self.developerProvidedBillingAndroid = developerProvidedBillingAndroid self.promotedProductIOS = promotedProductIOS @@ -2800,5 +2956,6 @@ public struct SubscriptionHandlers { self.purchaseUpdated = purchaseUpdated self.subscriptionBillingIssue = subscriptionBillingIssue self.userChoiceBillingAndroid = userChoiceBillingAndroid + self.webhookEvent = webhookEvent } } diff --git a/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt b/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt index 9697826f..4a434163 100644 --- a/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt +++ b/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt @@ -962,6 +962,250 @@ public enum class SubscriptionReplacementModeAndroid(val rawValue: String) { fun toJson(): String = rawValue } +public enum class SubscriptionState(val rawValue: String) { + Active("active"), + InGracePeriod("in-grace-period"), + InBillingRetry("in-billing-retry"), + Expired("expired"), + Revoked("revoked"), + Refunded("refunded"), + Paused("paused"), + Unknown("unknown"); + + companion object { + fun fromJson(value: String): SubscriptionState = when (value) { + "active" -> SubscriptionState.Active + "Active" -> SubscriptionState.Active + "in-grace-period" -> SubscriptionState.InGracePeriod + "InGracePeriod" -> SubscriptionState.InGracePeriod + "in-billing-retry" -> SubscriptionState.InBillingRetry + "InBillingRetry" -> SubscriptionState.InBillingRetry + "expired" -> SubscriptionState.Expired + "Expired" -> SubscriptionState.Expired + "revoked" -> SubscriptionState.Revoked + "Revoked" -> SubscriptionState.Revoked + "refunded" -> SubscriptionState.Refunded + "Refunded" -> SubscriptionState.Refunded + "paused" -> SubscriptionState.Paused + "Paused" -> SubscriptionState.Paused + "unknown" -> SubscriptionState.Unknown + "Unknown" -> SubscriptionState.Unknown + else -> throw IllegalArgumentException("Unknown SubscriptionState value: $value") + } + } + + fun toJson(): String = rawValue +} + +public enum class WebhookCancellationReason(val rawValue: String) { + UserCanceled("user-canceled"), + BillingError("billing-error"), + PriceIncreaseDeclined("price-increase-declined"), + ProductUnavailable("product-unavailable"), + Refunded("refunded"), + Other("other"); + + companion object { + fun fromJson(value: String): WebhookCancellationReason = when (value) { + "user-canceled" -> WebhookCancellationReason.UserCanceled + "USER_CANCELED" -> WebhookCancellationReason.UserCanceled + "UserCanceled" -> WebhookCancellationReason.UserCanceled + "billing-error" -> WebhookCancellationReason.BillingError + "BILLING_ERROR" -> WebhookCancellationReason.BillingError + "BillingError" -> WebhookCancellationReason.BillingError + "price-increase-declined" -> WebhookCancellationReason.PriceIncreaseDeclined + "PRICE_INCREASE_DECLINED" -> WebhookCancellationReason.PriceIncreaseDeclined + "PriceIncreaseDeclined" -> WebhookCancellationReason.PriceIncreaseDeclined + "product-unavailable" -> WebhookCancellationReason.ProductUnavailable + "PRODUCT_UNAVAILABLE" -> WebhookCancellationReason.ProductUnavailable + "ProductUnavailable" -> WebhookCancellationReason.ProductUnavailable + "refunded" -> WebhookCancellationReason.Refunded + "REFUNDED" -> WebhookCancellationReason.Refunded + "Refunded" -> WebhookCancellationReason.Refunded + "other" -> WebhookCancellationReason.Other + "OTHER" -> WebhookCancellationReason.Other + "Other" -> WebhookCancellationReason.Other + else -> throw IllegalArgumentException("Unknown WebhookCancellationReason value: $value") + } + } + + fun toJson(): String = rawValue +} + +public enum class WebhookEventEnvironment(val rawValue: String) { + Production("production"), + Sandbox("sandbox"), + Xcode("xcode"); + + companion object { + fun fromJson(value: String): WebhookEventEnvironment = when (value) { + "production" -> WebhookEventEnvironment.Production + "Production" -> WebhookEventEnvironment.Production + "sandbox" -> WebhookEventEnvironment.Sandbox + "Sandbox" -> WebhookEventEnvironment.Sandbox + "xcode" -> WebhookEventEnvironment.Xcode + "Xcode" -> WebhookEventEnvironment.Xcode + else -> throw IllegalArgumentException("Unknown WebhookEventEnvironment value: $value") + } + } + + fun toJson(): String = rawValue +} + +public enum class WebhookEventSource(val rawValue: String) { + AppleAppStoreServerNotificationsV2("apple-app-store-server-notifications-v2"), + GooglePlayRealTimeDeveloperNotifications("google-play-real-time-developer-notifications"); + + companion object { + fun fromJson(value: String): WebhookEventSource = when (value) { + "apple-app-store-server-notifications-v2" -> WebhookEventSource.AppleAppStoreServerNotificationsV2 + "AppleAppStoreServerNotificationsV2" -> WebhookEventSource.AppleAppStoreServerNotificationsV2 + "google-play-real-time-developer-notifications" -> WebhookEventSource.GooglePlayRealTimeDeveloperNotifications + "GooglePlayRealTimeDeveloperNotifications" -> WebhookEventSource.GooglePlayRealTimeDeveloperNotifications + else -> throw IllegalArgumentException("Unknown WebhookEventSource value: $value") + } + } + + fun toJson(): String = rawValue +} + +public enum class WebhookEventType(val rawValue: String) { + /** + * Initial purchase or first conversion from a free trial / intro offer. + * iOS: SUBSCRIBED (initialBuy / resubscribe). + * Android: SUBSCRIPTION_PURCHASED. + */ + SubscriptionStarted("subscription-started"), + /** + * Auto-renewal succeeded for an existing subscription. + * iOS: DID_RENEW. + * Android: SUBSCRIPTION_RENEWED. + */ + SubscriptionRenewed("subscription-renewed"), + /** + * Subscription reached its expiration without a successful renewal. + * iOS: EXPIRED. + * Android: SUBSCRIPTION_EXPIRED. + */ + SubscriptionExpired("subscription-expired"), + /** + * Billing failed; the subscription is in a grace period during which the user + * retains entitlement while payment is retried. + * iOS: DID_FAIL_TO_RENEW (with grace period active). + * Android: SUBSCRIPTION_IN_GRACE_PERIOD. + */ + SubscriptionInGracePeriod("subscription-in-grace-period"), + /** + * Billing failed and the subscription is in account-hold / billing retry, + * during which entitlement is paused but the subscription is not yet expired. + * iOS: DID_FAIL_TO_RENEW (no grace period; billing retry). + * Android: SUBSCRIPTION_ON_HOLD. + */ + SubscriptionInBillingRetry("subscription-in-billing-retry"), + /** + * 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"), + /** + * Access immediately revoked (family sharing removal, admin action, fraud). + * iOS: REVOKE. + * Android: SUBSCRIPTION_REVOKED. + */ + SubscriptionRevoked("subscription-revoked"), + /** + * A price change is pending or has been confirmed by the user. + * iOS: PRICE_INCREASE. + * Android: SUBSCRIPTION_PRICE_CHANGE_CONFIRMED. + */ + SubscriptionPriceChange("subscription-price-change"), + /** + * User upgraded, downgraded, or crossgraded their plan. + * iOS: DID_CHANGE_RENEWAL_PREF. + * Android: SUBSCRIPTION_DEFERRED / SUBSCRIPTION_PRODUCT_CHANGED. + */ + SubscriptionProductChanged("subscription-product-changed"), + /** + * Subscription paused (Android only feature). + * Android: SUBSCRIPTION_PAUSED. + */ + SubscriptionPaused("subscription-paused"), + /** + * Paused subscription resumed (Android only feature). + * Android: SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED / SUBSCRIPTION_RECOVERED from pause. + */ + SubscriptionResumed("subscription-resumed"), + /** + * Refund issued for a one-time purchase or subscription period. + * iOS: REFUND. + * Android: ONE_TIME_PRODUCT_REFUNDED / VOIDED_PURCHASE. + */ + PurchaseRefunded("purchase-refunded"), + /** + * iOS-only: App Store requests a consumption status report for a refund decision. + * Servers should respond via the StoreKit consumption API. + */ + PurchaseConsumptionRequest("purchase-consumption-request"), + /** + * Sandbox or test notification fired by the store for diagnostic purposes. + * Useful for verifying webhook plumbing without a live transaction. + */ + TestNotification("test-notification"); + + companion object { + fun fromJson(value: String): WebhookEventType = when (value) { + "subscription-started" -> WebhookEventType.SubscriptionStarted + "SubscriptionStarted" -> WebhookEventType.SubscriptionStarted + "subscription-renewed" -> WebhookEventType.SubscriptionRenewed + "SubscriptionRenewed" -> WebhookEventType.SubscriptionRenewed + "subscription-expired" -> WebhookEventType.SubscriptionExpired + "SubscriptionExpired" -> WebhookEventType.SubscriptionExpired + "subscription-in-grace-period" -> WebhookEventType.SubscriptionInGracePeriod + "SubscriptionInGracePeriod" -> WebhookEventType.SubscriptionInGracePeriod + "subscription-in-billing-retry" -> WebhookEventType.SubscriptionInBillingRetry + "SubscriptionInBillingRetry" -> WebhookEventType.SubscriptionInBillingRetry + "subscription-recovered" -> WebhookEventType.SubscriptionRecovered + "SubscriptionRecovered" -> WebhookEventType.SubscriptionRecovered + "subscription-canceled" -> WebhookEventType.SubscriptionCanceled + "SubscriptionCanceled" -> WebhookEventType.SubscriptionCanceled + "subscription-uncanceled" -> WebhookEventType.SubscriptionUncanceled + "SubscriptionUncanceled" -> WebhookEventType.SubscriptionUncanceled + "subscription-revoked" -> WebhookEventType.SubscriptionRevoked + "SubscriptionRevoked" -> WebhookEventType.SubscriptionRevoked + "subscription-price-change" -> WebhookEventType.SubscriptionPriceChange + "SubscriptionPriceChange" -> WebhookEventType.SubscriptionPriceChange + "subscription-product-changed" -> WebhookEventType.SubscriptionProductChanged + "SubscriptionProductChanged" -> WebhookEventType.SubscriptionProductChanged + "subscription-paused" -> WebhookEventType.SubscriptionPaused + "SubscriptionPaused" -> WebhookEventType.SubscriptionPaused + "subscription-resumed" -> WebhookEventType.SubscriptionResumed + "SubscriptionResumed" -> WebhookEventType.SubscriptionResumed + "purchase-refunded" -> WebhookEventType.PurchaseRefunded + "PurchaseRefunded" -> WebhookEventType.PurchaseRefunded + "purchase-consumption-request" -> WebhookEventType.PurchaseConsumptionRequest + "PurchaseConsumptionRequest" -> WebhookEventType.PurchaseConsumptionRequest + "test-notification" -> WebhookEventType.TestNotification + "TestNotification" -> WebhookEventType.TestNotification + else -> throw IllegalArgumentException("Unknown WebhookEventType value: $value") + } + } + + fun toJson(): String = rawValue +} + // MARK: - Interfaces public interface ProductCommon { @@ -3587,6 +3831,119 @@ public data class VerifyPurchaseWithProviderResult( public typealias VoidResult = Unit +public data class WebhookEvent( + /** + * Reason for cancellation, when applicable. + */ + val cancellationReason: WebhookCancellationReason? = null, + /** + * Localized currency code (ISO 4217) at event time, when available. + */ + val currency: String? = null, + val environment: WebhookEventEnvironment, + /** + * When the current subscription period ends. Epoch milliseconds. + */ + val expiresAt: Double? = null, + /** + * 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. + */ + val id: String, + /** + * Time the underlying event occurred at the store. Epoch milliseconds. + */ + val occurredAt: Double, + val 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. + */ + val priceAmountMicros: Double? = null, + /** + * Product the event pertains to. May be null for account-level events. + */ + val productId: String? = null, + /** + * kit project that owns the subscription / purchase this event refers to. + */ + val projectId: String, + /** + * Cross-platform purchase identity used to correlate this event with an existing + * purchase record. iOS: `originalTransactionId`. Android: `purchaseToken`. + */ + val purchaseToken: String, + /** + * Original signed payload from the store. ASN v2 events expose the JWS string; + * RTDN events expose the base64-decoded Pub/Sub message JSON. Provided so that + * consumers can independently verify or extract platform-specific fields. kit + * always validates this payload before emitting the event. + */ + val rawSignedPayload: String? = null, + /** + * Time kit ingested and normalized this event. Epoch milliseconds. + */ + val receivedAt: Double, + /** + * When auto-renewal will charge again. Epoch milliseconds. + */ + val renewsAt: Double? = null, + val source: WebhookEventSource, + /** + * Normalized subscription state at the time of event, when the event refers to + * a subscription. Null for one-time purchase events. + */ + val subscriptionState: SubscriptionState? = null, + val type: WebhookEventType +) { + + companion object { + fun fromJson(json: Map): 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, + ) + } + } + + fun toJson(): Map = mapOf( + "__typename" to "WebhookEvent", + "cancellationReason" to cancellationReason?.toJson(), + "currency" to currency, + "environment" to environment.toJson(), + "expiresAt" to expiresAt, + "id" to id, + "occurredAt" to occurredAt, + "platform" to platform.toJson(), + "priceAmountMicros" to priceAmountMicros, + "productId" to productId, + "projectId" to projectId, + "purchaseToken" to purchaseToken, + "rawSignedPayload" to rawSignedPayload, + "receivedAt" to receivedAt, + "renewsAt" to renewsAt, + "source" to source.toJson(), + "subscriptionState" to subscriptionState?.toJson(), + "type" to type.toJson(), + ) +} + // MARK: - Input Objects public data class AndroidSubscriptionOfferInput( @@ -5031,6 +5388,12 @@ public interface QueryResolver { * See: https://www.openiap.dev/docs/apis/ios/validate-receipt-ios */ suspend fun validateReceiptIOS(options: VerifyPurchaseProps): VerifyPurchaseResultIOS + /** + * 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. + */ + suspend fun webhookEventsSince(sinceMs: Double, limit: Int? = null): List } /** @@ -5076,6 +5439,16 @@ public interface SubscriptionResolver { * Only triggered when the user selects alternative billing instead of Google Play billing */ suspend fun userChoiceBillingAndroid(): UserChoiceBillingDetails + /** + * Streams normalized webhook events tied to the authenticated client's purchases. + * Clients only receive events whose `purchaseToken` matches a purchase they own. + * + * Transport: kit serves this over WebSocket. SDKs auto-connect when the host app + * enters foreground and disconnect when it goes to background. Events that fire + * while the connection is closed are reconciled via `webhookEventsSince` on + * reconnect or the next foreground entry. + */ + suspend fun webhookEvent(): WebhookEvent } // MARK: - Root Operation Helpers @@ -5161,6 +5534,7 @@ public typealias QueryIsTransactionVerifiedIOSHandler = suspend (sku: String) -> public typealias QueryLatestTransactionIOSHandler = suspend (sku: String) -> PurchaseIOS? public typealias QuerySubscriptionStatusIOSHandler = suspend (sku: String) -> List public typealias QueryValidateReceiptIOSHandler = suspend (options: VerifyPurchaseProps) -> VerifyPurchaseResultIOS +public typealias QueryWebhookEventsSinceHandler = suspend (sinceMs: Double, limit: Int?) -> List public data class QueryHandlers( val canPresentExternalPurchaseNoticeIOS: QueryCanPresentExternalPurchaseNoticeIOSHandler? = null, @@ -5183,7 +5557,8 @@ public data class QueryHandlers( val isTransactionVerifiedIOS: QueryIsTransactionVerifiedIOSHandler? = null, val latestTransactionIOS: QueryLatestTransactionIOSHandler? = null, val subscriptionStatusIOS: QuerySubscriptionStatusIOSHandler? = null, - val validateReceiptIOS: QueryValidateReceiptIOSHandler? = null + val validateReceiptIOS: QueryValidateReceiptIOSHandler? = null, + val webhookEventsSince: QueryWebhookEventsSinceHandler? = null ) // MARK: - Subscription Helpers @@ -5194,6 +5569,7 @@ public typealias SubscriptionPurchaseErrorHandler = suspend () -> PurchaseError public typealias SubscriptionPurchaseUpdatedHandler = suspend () -> Purchase public typealias SubscriptionSubscriptionBillingIssueHandler = suspend () -> Purchase public typealias SubscriptionUserChoiceBillingAndroidHandler = suspend () -> UserChoiceBillingDetails +public typealias SubscriptionWebhookEventHandler = suspend () -> WebhookEvent public data class SubscriptionHandlers( val developerProvidedBillingAndroid: SubscriptionDeveloperProvidedBillingAndroidHandler? = null, @@ -5201,5 +5577,6 @@ public data class SubscriptionHandlers( val purchaseError: SubscriptionPurchaseErrorHandler? = null, val purchaseUpdated: SubscriptionPurchaseUpdatedHandler? = null, val subscriptionBillingIssue: SubscriptionSubscriptionBillingIssueHandler? = null, - val userChoiceBillingAndroid: SubscriptionUserChoiceBillingAndroidHandler? = null + val userChoiceBillingAndroid: SubscriptionUserChoiceBillingAndroidHandler? = null, + val webhookEvent: SubscriptionWebhookEventHandler? = null ) diff --git a/packages/gql/codegen.ts b/packages/gql/codegen.ts index 9ec107cf..6bc8cb7e 100644 --- a/packages/gql/codegen.ts +++ b/packages/gql/codegen.ts @@ -11,6 +11,7 @@ const config: CodegenConfig = { 'src/api-android.graphql', 'src/error.graphql', 'src/event.graphql', + 'src/webhook.graphql', ], generates: { 'src/generated/types.ts': { diff --git a/packages/gql/codegen/core/parser.ts b/packages/gql/codegen/core/parser.ts index 86e9a5ec..48c64433 100644 --- a/packages/gql/codegen/core/parser.ts +++ b/packages/gql/codegen/core/parser.ts @@ -33,6 +33,7 @@ const DEFAULT_SCHEMA_PATHS = [ '../src/api-android.graphql', '../src/error.graphql', '../src/event.graphql', + '../src/webhook.graphql', ]; // ============================================================================ diff --git a/packages/gql/src/generated/Types.kt b/packages/gql/src/generated/Types.kt index 75ffa755..b27a04dc 100644 --- a/packages/gql/src/generated/Types.kt +++ b/packages/gql/src/generated/Types.kt @@ -1051,6 +1051,279 @@ public enum class SubscriptionReplacementModeAndroid(val rawValue: String) { fun toJson(): String = rawValue } +public enum class SubscriptionState(val rawValue: String) { + Active("active"), + InGracePeriod("in-grace-period"), + InBillingRetry("in-billing-retry"), + Expired("expired"), + Revoked("revoked"), + Refunded("refunded"), + Paused("paused"), + Unknown("unknown") + + companion object { + fun fromJson(value: String): SubscriptionState = when (value) { + "active" -> SubscriptionState.Active + "ACTIVE" -> SubscriptionState.Active + "Active" -> SubscriptionState.Active + "in-grace-period" -> SubscriptionState.InGracePeriod + "IN_GRACE_PERIOD" -> SubscriptionState.InGracePeriod + "InGracePeriod" -> SubscriptionState.InGracePeriod + "in-billing-retry" -> SubscriptionState.InBillingRetry + "IN_BILLING_RETRY" -> SubscriptionState.InBillingRetry + "InBillingRetry" -> SubscriptionState.InBillingRetry + "expired" -> SubscriptionState.Expired + "EXPIRED" -> SubscriptionState.Expired + "Expired" -> SubscriptionState.Expired + "revoked" -> SubscriptionState.Revoked + "REVOKED" -> SubscriptionState.Revoked + "Revoked" -> SubscriptionState.Revoked + "refunded" -> SubscriptionState.Refunded + "REFUNDED" -> SubscriptionState.Refunded + "Refunded" -> SubscriptionState.Refunded + "paused" -> SubscriptionState.Paused + "PAUSED" -> SubscriptionState.Paused + "Paused" -> SubscriptionState.Paused + "unknown" -> SubscriptionState.Unknown + "UNKNOWN" -> SubscriptionState.Unknown + "Unknown" -> SubscriptionState.Unknown + else -> throw IllegalArgumentException("Unknown SubscriptionState value: $value") + } + } + + fun toJson(): String = rawValue +} + +public enum class WebhookCancellationReason(val rawValue: String) { + UserCanceled("user-canceled"), + BillingError("billing-error"), + PriceIncreaseDeclined("price-increase-declined"), + ProductUnavailable("product-unavailable"), + Refunded("refunded"), + Other("other") + + companion object { + fun fromJson(value: String): WebhookCancellationReason = when (value) { + "user-canceled" -> WebhookCancellationReason.UserCanceled + "USER_CANCELED" -> WebhookCancellationReason.UserCanceled + "UserCanceled" -> WebhookCancellationReason.UserCanceled + "billing-error" -> WebhookCancellationReason.BillingError + "BILLING_ERROR" -> WebhookCancellationReason.BillingError + "BillingError" -> WebhookCancellationReason.BillingError + "price-increase-declined" -> WebhookCancellationReason.PriceIncreaseDeclined + "PRICE_INCREASE_DECLINED" -> WebhookCancellationReason.PriceIncreaseDeclined + "PriceIncreaseDeclined" -> WebhookCancellationReason.PriceIncreaseDeclined + "product-unavailable" -> WebhookCancellationReason.ProductUnavailable + "PRODUCT_UNAVAILABLE" -> WebhookCancellationReason.ProductUnavailable + "ProductUnavailable" -> WebhookCancellationReason.ProductUnavailable + "refunded" -> WebhookCancellationReason.Refunded + "REFUNDED" -> WebhookCancellationReason.Refunded + "Refunded" -> WebhookCancellationReason.Refunded + "other" -> WebhookCancellationReason.Other + "OTHER" -> WebhookCancellationReason.Other + "Other" -> WebhookCancellationReason.Other + else -> throw IllegalArgumentException("Unknown WebhookCancellationReason value: $value") + } + } + + fun toJson(): String = rawValue +} + +public enum class WebhookEventEnvironment(val rawValue: String) { + Production("production"), + Sandbox("sandbox"), + Xcode("xcode") + + companion object { + fun fromJson(value: String): WebhookEventEnvironment = when (value) { + "production" -> WebhookEventEnvironment.Production + "PRODUCTION" -> WebhookEventEnvironment.Production + "Production" -> WebhookEventEnvironment.Production + "sandbox" -> WebhookEventEnvironment.Sandbox + "SANDBOX" -> WebhookEventEnvironment.Sandbox + "Sandbox" -> WebhookEventEnvironment.Sandbox + "xcode" -> WebhookEventEnvironment.Xcode + "XCODE" -> WebhookEventEnvironment.Xcode + "Xcode" -> WebhookEventEnvironment.Xcode + else -> throw IllegalArgumentException("Unknown WebhookEventEnvironment value: $value") + } + } + + fun toJson(): String = rawValue +} + +public enum class WebhookEventSource(val rawValue: String) { + AppleAppStoreServerNotificationsV2("apple-app-store-server-notifications-v2"), + GooglePlayRealTimeDeveloperNotifications("google-play-real-time-developer-notifications") + + companion object { + fun fromJson(value: String): WebhookEventSource = when (value) { + "apple-app-store-server-notifications-v2" -> WebhookEventSource.AppleAppStoreServerNotificationsV2 + "APPLE_APP_STORE_SERVER_NOTIFICATIONS_V2" -> WebhookEventSource.AppleAppStoreServerNotificationsV2 + "AppleAppStoreServerNotificationsV2" -> WebhookEventSource.AppleAppStoreServerNotificationsV2 + "google-play-real-time-developer-notifications" -> WebhookEventSource.GooglePlayRealTimeDeveloperNotifications + "GOOGLE_PLAY_REAL_TIME_DEVELOPER_NOTIFICATIONS" -> WebhookEventSource.GooglePlayRealTimeDeveloperNotifications + "GooglePlayRealTimeDeveloperNotifications" -> WebhookEventSource.GooglePlayRealTimeDeveloperNotifications + else -> throw IllegalArgumentException("Unknown WebhookEventSource value: $value") + } + } + + fun toJson(): String = rawValue +} + +public enum class WebhookEventType(val rawValue: String) { + /** + * Initial purchase or first conversion from a free trial / intro offer. + * iOS: SUBSCRIBED (initialBuy / resubscribe). + * Android: SUBSCRIPTION_PURCHASED. + */ + SubscriptionStarted("subscription-started"), + /** + * Auto-renewal succeeded for an existing subscription. + * iOS: DID_RENEW. + * Android: SUBSCRIPTION_RENEWED. + */ + SubscriptionRenewed("subscription-renewed"), + /** + * Subscription reached its expiration without a successful renewal. + * iOS: EXPIRED. + * Android: SUBSCRIPTION_EXPIRED. + */ + SubscriptionExpired("subscription-expired"), + /** + * Billing failed; the subscription is in a grace period during which the user + * retains entitlement while payment is retried. + * iOS: DID_FAIL_TO_RENEW (with grace period active). + * Android: SUBSCRIPTION_IN_GRACE_PERIOD. + */ + SubscriptionInGracePeriod("subscription-in-grace-period"), + /** + * Billing failed and the subscription is in account-hold / billing retry, + * during which entitlement is paused but the subscription is not yet expired. + * iOS: DID_FAIL_TO_RENEW (no grace period; billing retry). + * Android: SUBSCRIPTION_ON_HOLD. + */ + SubscriptionInBillingRetry("subscription-in-billing-retry"), + /** + * 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"), + /** + * Access immediately revoked (family sharing removal, admin action, fraud). + * iOS: REVOKE. + * Android: SUBSCRIPTION_REVOKED. + */ + SubscriptionRevoked("subscription-revoked"), + /** + * A price change is pending or has been confirmed by the user. + * iOS: PRICE_INCREASE. + * Android: SUBSCRIPTION_PRICE_CHANGE_CONFIRMED. + */ + SubscriptionPriceChange("subscription-price-change"), + /** + * User upgraded, downgraded, or crossgraded their plan. + * iOS: DID_CHANGE_RENEWAL_PREF. + * Android: SUBSCRIPTION_DEFERRED / SUBSCRIPTION_PRODUCT_CHANGED. + */ + SubscriptionProductChanged("subscription-product-changed"), + /** + * Subscription paused (Android only feature). + * Android: SUBSCRIPTION_PAUSED. + */ + SubscriptionPaused("subscription-paused"), + /** + * Paused subscription resumed (Android only feature). + * Android: SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED / SUBSCRIPTION_RECOVERED from pause. + */ + SubscriptionResumed("subscription-resumed"), + /** + * Refund issued for a one-time purchase or subscription period. + * iOS: REFUND. + * Android: ONE_TIME_PRODUCT_REFUNDED / VOIDED_PURCHASE. + */ + PurchaseRefunded("purchase-refunded"), + /** + * iOS-only: App Store requests a consumption status report for a refund decision. + * Servers should respond via the StoreKit consumption API. + */ + PurchaseConsumptionRequest("purchase-consumption-request"), + /** + * Sandbox or test notification fired by the store for diagnostic purposes. + * Useful for verifying webhook plumbing without a live transaction. + */ + TestNotification("test-notification") + + companion object { + fun fromJson(value: String): WebhookEventType = when (value) { + "subscription-started" -> WebhookEventType.SubscriptionStarted + "SUBSCRIPTION_STARTED" -> WebhookEventType.SubscriptionStarted + "SubscriptionStarted" -> WebhookEventType.SubscriptionStarted + "subscription-renewed" -> WebhookEventType.SubscriptionRenewed + "SUBSCRIPTION_RENEWED" -> WebhookEventType.SubscriptionRenewed + "SubscriptionRenewed" -> WebhookEventType.SubscriptionRenewed + "subscription-expired" -> WebhookEventType.SubscriptionExpired + "SUBSCRIPTION_EXPIRED" -> WebhookEventType.SubscriptionExpired + "SubscriptionExpired" -> WebhookEventType.SubscriptionExpired + "subscription-in-grace-period" -> WebhookEventType.SubscriptionInGracePeriod + "SUBSCRIPTION_IN_GRACE_PERIOD" -> WebhookEventType.SubscriptionInGracePeriod + "SubscriptionInGracePeriod" -> WebhookEventType.SubscriptionInGracePeriod + "subscription-in-billing-retry" -> WebhookEventType.SubscriptionInBillingRetry + "SUBSCRIPTION_IN_BILLING_RETRY" -> WebhookEventType.SubscriptionInBillingRetry + "SubscriptionInBillingRetry" -> WebhookEventType.SubscriptionInBillingRetry + "subscription-recovered" -> WebhookEventType.SubscriptionRecovered + "SUBSCRIPTION_RECOVERED" -> WebhookEventType.SubscriptionRecovered + "SubscriptionRecovered" -> WebhookEventType.SubscriptionRecovered + "subscription-canceled" -> WebhookEventType.SubscriptionCanceled + "SUBSCRIPTION_CANCELED" -> WebhookEventType.SubscriptionCanceled + "SubscriptionCanceled" -> WebhookEventType.SubscriptionCanceled + "subscription-uncanceled" -> WebhookEventType.SubscriptionUncanceled + "SUBSCRIPTION_UNCANCELED" -> WebhookEventType.SubscriptionUncanceled + "SubscriptionUncanceled" -> WebhookEventType.SubscriptionUncanceled + "subscription-revoked" -> WebhookEventType.SubscriptionRevoked + "SUBSCRIPTION_REVOKED" -> WebhookEventType.SubscriptionRevoked + "SubscriptionRevoked" -> WebhookEventType.SubscriptionRevoked + "subscription-price-change" -> WebhookEventType.SubscriptionPriceChange + "SUBSCRIPTION_PRICE_CHANGE" -> WebhookEventType.SubscriptionPriceChange + "SubscriptionPriceChange" -> WebhookEventType.SubscriptionPriceChange + "subscription-product-changed" -> WebhookEventType.SubscriptionProductChanged + "SUBSCRIPTION_PRODUCT_CHANGED" -> WebhookEventType.SubscriptionProductChanged + "SubscriptionProductChanged" -> WebhookEventType.SubscriptionProductChanged + "subscription-paused" -> WebhookEventType.SubscriptionPaused + "SUBSCRIPTION_PAUSED" -> WebhookEventType.SubscriptionPaused + "SubscriptionPaused" -> WebhookEventType.SubscriptionPaused + "subscription-resumed" -> WebhookEventType.SubscriptionResumed + "SUBSCRIPTION_RESUMED" -> WebhookEventType.SubscriptionResumed + "SubscriptionResumed" -> WebhookEventType.SubscriptionResumed + "purchase-refunded" -> WebhookEventType.PurchaseRefunded + "PURCHASE_REFUNDED" -> WebhookEventType.PurchaseRefunded + "PurchaseRefunded" -> WebhookEventType.PurchaseRefunded + "purchase-consumption-request" -> WebhookEventType.PurchaseConsumptionRequest + "PURCHASE_CONSUMPTION_REQUEST" -> WebhookEventType.PurchaseConsumptionRequest + "PurchaseConsumptionRequest" -> WebhookEventType.PurchaseConsumptionRequest + "test-notification" -> WebhookEventType.TestNotification + "TEST_NOTIFICATION" -> WebhookEventType.TestNotification + "TestNotification" -> WebhookEventType.TestNotification + else -> throw IllegalArgumentException("Unknown WebhookEventType value: $value") + } + } + + fun toJson(): String = rawValue +} + // MARK: - Interfaces public interface ProductCommon { @@ -3676,6 +3949,119 @@ public data class VerifyPurchaseWithProviderResult( public typealias VoidResult = Unit +public data class WebhookEvent( + /** + * Reason for cancellation, when applicable. + */ + val cancellationReason: WebhookCancellationReason? = null, + /** + * Localized currency code (ISO 4217) at event time, when available. + */ + val currency: String? = null, + val environment: WebhookEventEnvironment, + /** + * When the current subscription period ends. Epoch milliseconds. + */ + val expiresAt: Double? = null, + /** + * 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. + */ + val id: String, + /** + * Time the underlying event occurred at the store. Epoch milliseconds. + */ + val occurredAt: Double, + val 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. + */ + val priceAmountMicros: Double? = null, + /** + * Product the event pertains to. May be null for account-level events. + */ + val productId: String? = null, + /** + * kit project that owns the subscription / purchase this event refers to. + */ + val projectId: String, + /** + * Cross-platform purchase identity used to correlate this event with an existing + * purchase record. iOS: `originalTransactionId`. Android: `purchaseToken`. + */ + val purchaseToken: String, + /** + * Original signed payload from the store. ASN v2 events expose the JWS string; + * RTDN events expose the base64-decoded Pub/Sub message JSON. Provided so that + * consumers can independently verify or extract platform-specific fields. kit + * always validates this payload before emitting the event. + */ + val rawSignedPayload: String? = null, + /** + * Time kit ingested and normalized this event. Epoch milliseconds. + */ + val receivedAt: Double, + /** + * When auto-renewal will charge again. Epoch milliseconds. + */ + val renewsAt: Double? = null, + val source: WebhookEventSource, + /** + * Normalized subscription state at the time of event, when the event refers to + * a subscription. Null for one-time purchase events. + */ + val subscriptionState: SubscriptionState? = null, + val type: WebhookEventType +) { + + companion object { + fun fromJson(json: Map): 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, + ) + } + } + + fun toJson(): Map = mapOf( + "__typename" to "WebhookEvent", + "cancellationReason" to cancellationReason?.toJson(), + "currency" to currency, + "environment" to environment.toJson(), + "expiresAt" to expiresAt, + "id" to id, + "occurredAt" to occurredAt, + "platform" to platform.toJson(), + "priceAmountMicros" to priceAmountMicros, + "productId" to productId, + "projectId" to projectId, + "purchaseToken" to purchaseToken, + "rawSignedPayload" to rawSignedPayload, + "receivedAt" to receivedAt, + "renewsAt" to renewsAt, + "source" to source.toJson(), + "subscriptionState" to subscriptionState?.toJson(), + "type" to type.toJson(), + ) +} + // MARK: - Input Objects public data class AndroidSubscriptionOfferInput( @@ -5120,6 +5506,12 @@ public interface QueryResolver { * See: https://www.openiap.dev/docs/apis/ios/validate-receipt-ios */ suspend fun validateReceiptIOS(options: VerifyPurchaseProps): VerifyPurchaseResultIOS + /** + * 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. + */ + suspend fun webhookEventsSince(sinceMs: Double, limit: Int? = null): List } /** @@ -5165,6 +5557,16 @@ public interface SubscriptionResolver { * Only triggered when the user selects alternative billing instead of Google Play billing */ suspend fun userChoiceBillingAndroid(): UserChoiceBillingDetails + /** + * Streams normalized webhook events tied to the authenticated client's purchases. + * Clients only receive events whose `purchaseToken` matches a purchase they own. + * + * Transport: kit serves this over WebSocket. SDKs auto-connect when the host app + * enters foreground and disconnect when it goes to background. Events that fire + * while the connection is closed are reconciled via `webhookEventsSince` on + * reconnect or the next foreground entry. + */ + suspend fun webhookEvent(): WebhookEvent } // MARK: - Root Operation Helpers @@ -5250,6 +5652,7 @@ public typealias QueryIsTransactionVerifiedIOSHandler = suspend (sku: String) -> public typealias QueryLatestTransactionIOSHandler = suspend (sku: String) -> PurchaseIOS? public typealias QuerySubscriptionStatusIOSHandler = suspend (sku: String) -> List public typealias QueryValidateReceiptIOSHandler = suspend (options: VerifyPurchaseProps) -> VerifyPurchaseResultIOS +public typealias QueryWebhookEventsSinceHandler = suspend (sinceMs: Double, limit: Int?) -> List public data class QueryHandlers( val canPresentExternalPurchaseNoticeIOS: QueryCanPresentExternalPurchaseNoticeIOSHandler? = null, @@ -5272,7 +5675,8 @@ public data class QueryHandlers( val isTransactionVerifiedIOS: QueryIsTransactionVerifiedIOSHandler? = null, val latestTransactionIOS: QueryLatestTransactionIOSHandler? = null, val subscriptionStatusIOS: QuerySubscriptionStatusIOSHandler? = null, - val validateReceiptIOS: QueryValidateReceiptIOSHandler? = null + val validateReceiptIOS: QueryValidateReceiptIOSHandler? = null, + val webhookEventsSince: QueryWebhookEventsSinceHandler? = null ) // MARK: - Subscription Helpers @@ -5283,6 +5687,7 @@ public typealias SubscriptionPurchaseErrorHandler = suspend () -> PurchaseError public typealias SubscriptionPurchaseUpdatedHandler = suspend () -> Purchase public typealias SubscriptionSubscriptionBillingIssueHandler = suspend () -> Purchase public typealias SubscriptionUserChoiceBillingAndroidHandler = suspend () -> UserChoiceBillingDetails +public typealias SubscriptionWebhookEventHandler = suspend () -> WebhookEvent public data class SubscriptionHandlers( val developerProvidedBillingAndroid: SubscriptionDeveloperProvidedBillingAndroidHandler? = null, @@ -5290,5 +5695,6 @@ public data class SubscriptionHandlers( val purchaseError: SubscriptionPurchaseErrorHandler? = null, val purchaseUpdated: SubscriptionPurchaseUpdatedHandler? = null, val subscriptionBillingIssue: SubscriptionSubscriptionBillingIssueHandler? = null, - val userChoiceBillingAndroid: SubscriptionUserChoiceBillingAndroidHandler? = null + val userChoiceBillingAndroid: SubscriptionUserChoiceBillingAndroidHandler? = null, + val webhookEvent: SubscriptionWebhookEventHandler? = null ) diff --git a/packages/gql/src/generated/Types.swift b/packages/gql/src/generated/Types.swift index efacb85c..39962124 100644 --- a/packages/gql/src/generated/Types.swift +++ b/packages/gql/src/generated/Types.swift @@ -424,6 +424,102 @@ public enum SubscriptionReplacementModeAndroid: String, Codable, CaseIterable { case keepExisting = "keep-existing" } +public enum SubscriptionState: String, Codable, CaseIterable { + case active = "active" + case inGracePeriod = "in-grace-period" + case inBillingRetry = "in-billing-retry" + case expired = "expired" + case revoked = "revoked" + case refunded = "refunded" + case paused = "paused" + case unknown = "unknown" +} + +public enum WebhookCancellationReason: String, Codable, CaseIterable { + case userCanceled = "user-canceled" + case billingError = "billing-error" + case priceIncreaseDeclined = "price-increase-declined" + case productUnavailable = "product-unavailable" + case refunded = "refunded" + case other = "other" +} + +public enum WebhookEventEnvironment: String, Codable, CaseIterable { + case production = "production" + case sandbox = "sandbox" + case xcode = "xcode" +} + +public enum WebhookEventSource: String, Codable, CaseIterable { + case appleAppStoreServerNotificationsV2 = "apple-app-store-server-notifications-v2" + case googlePlayRealTimeDeveloperNotifications = "google-play-real-time-developer-notifications" +} + +public enum WebhookEventType: String, Codable, CaseIterable { + /// Initial purchase or first conversion from a free trial / intro offer. + /// iOS: SUBSCRIBED (initialBuy / resubscribe). + /// Android: SUBSCRIPTION_PURCHASED. + case subscriptionStarted = "subscription-started" + /// Auto-renewal succeeded for an existing subscription. + /// iOS: DID_RENEW. + /// Android: SUBSCRIPTION_RENEWED. + case subscriptionRenewed = "subscription-renewed" + /// Subscription reached its expiration without a successful renewal. + /// iOS: EXPIRED. + /// Android: SUBSCRIPTION_EXPIRED. + case subscriptionExpired = "subscription-expired" + /// Billing failed; the subscription is in a grace period during which the user + /// retains entitlement while payment is retried. + /// iOS: DID_FAIL_TO_RENEW (with grace period active). + /// Android: SUBSCRIPTION_IN_GRACE_PERIOD. + case subscriptionInGracePeriod = "subscription-in-grace-period" + /// Billing failed and the subscription is in account-hold / billing retry, + /// during which entitlement is paused but the subscription is not yet expired. + /// iOS: DID_FAIL_TO_RENEW (no grace period; billing retry). + /// Android: SUBSCRIPTION_ON_HOLD. + case subscriptionInBillingRetry = "subscription-in-billing-retry" + /// Subscription returned to active state after a billing issue or pause. + /// iOS: DID_RECOVER. + /// Android: SUBSCRIPTION_RECOVERED / SUBSCRIPTION_RESTARTED. + case 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. + case 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). + case subscriptionUncanceled = "subscription-uncanceled" + /// Access immediately revoked (family sharing removal, admin action, fraud). + /// iOS: REVOKE. + /// Android: SUBSCRIPTION_REVOKED. + case subscriptionRevoked = "subscription-revoked" + /// A price change is pending or has been confirmed by the user. + /// iOS: PRICE_INCREASE. + /// Android: SUBSCRIPTION_PRICE_CHANGE_CONFIRMED. + case subscriptionPriceChange = "subscription-price-change" + /// User upgraded, downgraded, or crossgraded their plan. + /// iOS: DID_CHANGE_RENEWAL_PREF. + /// Android: SUBSCRIPTION_DEFERRED / SUBSCRIPTION_PRODUCT_CHANGED. + case subscriptionProductChanged = "subscription-product-changed" + /// Subscription paused (Android only feature). + /// Android: SUBSCRIPTION_PAUSED. + case subscriptionPaused = "subscription-paused" + /// Paused subscription resumed (Android only feature). + /// Android: SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED / SUBSCRIPTION_RECOVERED from pause. + case subscriptionResumed = "subscription-resumed" + /// Refund issued for a one-time purchase or subscription period. + /// iOS: REFUND. + /// Android: ONE_TIME_PRODUCT_REFUNDED / VOIDED_PURCHASE. + case purchaseRefunded = "purchase-refunded" + /// iOS-only: App Store requests a consumption status report for a refund decision. + /// Servers should respond via the StoreKit consumption API. + case purchaseConsumptionRequest = "purchase-consumption-request" + /// Sandbox or test notification fired by the store for diagnostic purposes. + /// Useful for verifying webhook plumbing without a live transaction. + case testNotification = "test-notification" +} + // MARK: - Interfaces public protocol ProductCommon: Codable { @@ -1320,6 +1416,47 @@ public struct VerifyPurchaseWithProviderResult: Codable { public typealias VoidResult = Void +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; + /// RTDN events expose the base64-decoded Pub/Sub message JSON. Provided so that + /// consumers can independently verify or extract platform-specific fields. kit + /// always validates this payload before emitting the event. + public var rawSignedPayload: String? = nil + /// Time kit ingested and normalized this event. Epoch milliseconds. + public var receivedAt: Double + /// When auto-renewal will charge again. Epoch milliseconds. + public var renewsAt: Double? = nil + public var source: WebhookEventSource + /// Normalized subscription state at the time of event, when the event refers to + /// a subscription. Null for one-time purchase events. + public var subscriptionState: SubscriptionState? = nil + public var type: WebhookEventType +} + // MARK: - Input Objects public struct AndroidSubscriptionOfferInput: Codable { @@ -2526,6 +2663,10 @@ public protocol QueryResolver { /// Deprecated. Legacy App Store receipt validation — use verifyPurchase instead. /// See: https://www.openiap.dev/docs/apis/ios/validate-receipt-ios func validateReceiptIOS(_ options: VerifyPurchaseProps) async throws -> VerifyPurchaseResultIOS + /// 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. + func webhookEventsSince(sinceMs: Double, limit: Int?) async throws -> [WebhookEvent] } /// GraphQL root subscription operations. @@ -2557,6 +2698,14 @@ public protocol SubscriptionResolver { /// Fires when a user selects alternative billing in the User Choice Billing dialog (Android only) /// Only triggered when the user selects alternative billing instead of Google Play billing func userChoiceBillingAndroid() async throws -> UserChoiceBillingDetails + /// Streams normalized webhook events tied to the authenticated client's purchases. + /// Clients only receive events whose `purchaseToken` matches a purchase they own. + /// + /// Transport: kit serves this over WebSocket. SDKs auto-connect when the host app + /// enters foreground and disconnect when it goes to background. Events that fire + /// while the connection is closed are reconciled via `webhookEventsSince` on + /// reconnect or the next foreground entry. + func webhookEvent() async throws -> WebhookEvent } // MARK: - Root Operation Helpers @@ -2698,6 +2847,7 @@ public typealias QueryIsTransactionVerifiedIOSHandler = (_ sku: String) async th public typealias QueryLatestTransactionIOSHandler = (_ sku: String) async throws -> PurchaseIOS? public typealias QuerySubscriptionStatusIOSHandler = (_ sku: String) async throws -> [SubscriptionStatusIOS] public typealias QueryValidateReceiptIOSHandler = (_ options: VerifyPurchaseProps) async throws -> VerifyPurchaseResultIOS +public typealias QueryWebhookEventsSinceHandler = (_ sinceMs: Double, _ limit: Int?) async throws -> [WebhookEvent] public struct QueryHandlers { public var canPresentExternalPurchaseNoticeIOS: QueryCanPresentExternalPurchaseNoticeIOSHandler? @@ -2721,6 +2871,7 @@ public struct QueryHandlers { public var latestTransactionIOS: QueryLatestTransactionIOSHandler? public var subscriptionStatusIOS: QuerySubscriptionStatusIOSHandler? public var validateReceiptIOS: QueryValidateReceiptIOSHandler? + public var webhookEventsSince: QueryWebhookEventsSinceHandler? public init( canPresentExternalPurchaseNoticeIOS: QueryCanPresentExternalPurchaseNoticeIOSHandler? = nil, @@ -2743,7 +2894,8 @@ public struct QueryHandlers { isTransactionVerifiedIOS: QueryIsTransactionVerifiedIOSHandler? = nil, latestTransactionIOS: QueryLatestTransactionIOSHandler? = nil, subscriptionStatusIOS: QuerySubscriptionStatusIOSHandler? = nil, - validateReceiptIOS: QueryValidateReceiptIOSHandler? = nil + validateReceiptIOS: QueryValidateReceiptIOSHandler? = nil, + webhookEventsSince: QueryWebhookEventsSinceHandler? = nil ) { self.canPresentExternalPurchaseNoticeIOS = canPresentExternalPurchaseNoticeIOS self.currentEntitlementIOS = currentEntitlementIOS @@ -2766,6 +2918,7 @@ public struct QueryHandlers { self.latestTransactionIOS = latestTransactionIOS self.subscriptionStatusIOS = subscriptionStatusIOS self.validateReceiptIOS = validateReceiptIOS + self.webhookEventsSince = webhookEventsSince } } @@ -2777,6 +2930,7 @@ public typealias SubscriptionPurchaseErrorHandler = () async throws -> PurchaseE public typealias SubscriptionPurchaseUpdatedHandler = () async throws -> Purchase public typealias SubscriptionSubscriptionBillingIssueHandler = () async throws -> Purchase public typealias SubscriptionUserChoiceBillingAndroidHandler = () async throws -> UserChoiceBillingDetails +public typealias SubscriptionWebhookEventHandler = () async throws -> WebhookEvent public struct SubscriptionHandlers { public var developerProvidedBillingAndroid: SubscriptionDeveloperProvidedBillingAndroidHandler? @@ -2785,6 +2939,7 @@ public struct SubscriptionHandlers { public var purchaseUpdated: SubscriptionPurchaseUpdatedHandler? public var subscriptionBillingIssue: SubscriptionSubscriptionBillingIssueHandler? public var userChoiceBillingAndroid: SubscriptionUserChoiceBillingAndroidHandler? + public var webhookEvent: SubscriptionWebhookEventHandler? public init( developerProvidedBillingAndroid: SubscriptionDeveloperProvidedBillingAndroidHandler? = nil, @@ -2792,7 +2947,8 @@ public struct SubscriptionHandlers { purchaseError: SubscriptionPurchaseErrorHandler? = nil, purchaseUpdated: SubscriptionPurchaseUpdatedHandler? = nil, subscriptionBillingIssue: SubscriptionSubscriptionBillingIssueHandler? = nil, - userChoiceBillingAndroid: SubscriptionUserChoiceBillingAndroidHandler? = nil + userChoiceBillingAndroid: SubscriptionUserChoiceBillingAndroidHandler? = nil, + webhookEvent: SubscriptionWebhookEventHandler? = nil ) { self.developerProvidedBillingAndroid = developerProvidedBillingAndroid self.promotedProductIOS = promotedProductIOS @@ -2800,5 +2956,6 @@ public struct SubscriptionHandlers { self.purchaseUpdated = purchaseUpdated self.subscriptionBillingIssue = subscriptionBillingIssue self.userChoiceBillingAndroid = userChoiceBillingAndroid + self.webhookEvent = webhookEvent } } diff --git a/packages/gql/src/generated/types.dart b/packages/gql/src/generated/types.dart index 1645922e..e9af67a7 100644 --- a/packages/gql/src/generated/types.dart +++ b/packages/gql/src/generated/types.dart @@ -928,6 +928,232 @@ enum SubscriptionReplacementModeAndroid { String toJson() => value; } +enum SubscriptionState { + Active('active'), + InGracePeriod('in-grace-period'), + InBillingRetry('in-billing-retry'), + Expired('expired'), + Revoked('revoked'), + Refunded('refunded'), + Paused('paused'), + Unknown('unknown'); + + const SubscriptionState(this.value); + final String value; + + factory SubscriptionState.fromJson(String value) { + final normalized = value.toLowerCase().replaceAll('_', '-'); + switch (normalized) { + case 'active': + return SubscriptionState.Active; + case 'in-grace-period': + return SubscriptionState.InGracePeriod; + case 'in-billing-retry': + return SubscriptionState.InBillingRetry; + case 'expired': + return SubscriptionState.Expired; + case 'revoked': + return SubscriptionState.Revoked; + case 'refunded': + return SubscriptionState.Refunded; + case 'paused': + return SubscriptionState.Paused; + case 'unknown': + return SubscriptionState.Unknown; + } + throw ArgumentError('Unknown SubscriptionState value: $value'); + } + + String toJson() => value; +} + +enum WebhookCancellationReason { + UserCanceled('user-canceled'), + BillingError('billing-error'), + PriceIncreaseDeclined('price-increase-declined'), + ProductUnavailable('product-unavailable'), + Refunded('refunded'), + Other('other'); + + const WebhookCancellationReason(this.value); + final String value; + + factory WebhookCancellationReason.fromJson(String value) { + final normalized = value.toLowerCase().replaceAll('_', '-'); + switch (normalized) { + case 'user-canceled': + return WebhookCancellationReason.UserCanceled; + case 'billing-error': + return WebhookCancellationReason.BillingError; + case 'price-increase-declined': + return WebhookCancellationReason.PriceIncreaseDeclined; + case 'product-unavailable': + return WebhookCancellationReason.ProductUnavailable; + case 'refunded': + return WebhookCancellationReason.Refunded; + case 'other': + return WebhookCancellationReason.Other; + } + throw ArgumentError('Unknown WebhookCancellationReason value: $value'); + } + + String toJson() => value; +} + +enum WebhookEventEnvironment { + Production('production'), + Sandbox('sandbox'), + Xcode('xcode'); + + const WebhookEventEnvironment(this.value); + final String value; + + factory WebhookEventEnvironment.fromJson(String value) { + final normalized = value.toLowerCase().replaceAll('_', '-'); + switch (normalized) { + case 'production': + return WebhookEventEnvironment.Production; + case 'sandbox': + return WebhookEventEnvironment.Sandbox; + case 'xcode': + return WebhookEventEnvironment.Xcode; + } + throw ArgumentError('Unknown WebhookEventEnvironment value: $value'); + } + + String toJson() => value; +} + +enum WebhookEventSource { + AppleAppStoreServerNotificationsV2('apple-app-store-server-notifications-v2'), + GooglePlayRealTimeDeveloperNotifications('google-play-real-time-developer-notifications'); + + const WebhookEventSource(this.value); + final String value; + + factory WebhookEventSource.fromJson(String value) { + final normalized = value.toLowerCase().replaceAll('_', '-'); + switch (normalized) { + case 'apple-app-store-server-notifications-v2': + return WebhookEventSource.AppleAppStoreServerNotificationsV2; + case 'google-play-real-time-developer-notifications': + return WebhookEventSource.GooglePlayRealTimeDeveloperNotifications; + } + throw ArgumentError('Unknown WebhookEventSource value: $value'); + } + + String toJson() => value; +} + +enum WebhookEventType { + /// Initial purchase or first conversion from a free trial / intro offer. + /// iOS: SUBSCRIBED (initialBuy / resubscribe). + /// Android: SUBSCRIPTION_PURCHASED. + SubscriptionStarted('subscription-started'), + /// Auto-renewal succeeded for an existing subscription. + /// iOS: DID_RENEW. + /// Android: SUBSCRIPTION_RENEWED. + SubscriptionRenewed('subscription-renewed'), + /// Subscription reached its expiration without a successful renewal. + /// iOS: EXPIRED. + /// Android: SUBSCRIPTION_EXPIRED. + SubscriptionExpired('subscription-expired'), + /// Billing failed; the subscription is in a grace period during which the user + /// retains entitlement while payment is retried. + /// iOS: DID_FAIL_TO_RENEW (with grace period active). + /// Android: SUBSCRIPTION_IN_GRACE_PERIOD. + SubscriptionInGracePeriod('subscription-in-grace-period'), + /// Billing failed and the subscription is in account-hold / billing retry, + /// during which entitlement is paused but the subscription is not yet expired. + /// iOS: DID_FAIL_TO_RENEW (no grace period; billing retry). + /// Android: SUBSCRIPTION_ON_HOLD. + SubscriptionInBillingRetry('subscription-in-billing-retry'), + /// 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'), + /// Access immediately revoked (family sharing removal, admin action, fraud). + /// iOS: REVOKE. + /// Android: SUBSCRIPTION_REVOKED. + SubscriptionRevoked('subscription-revoked'), + /// A price change is pending or has been confirmed by the user. + /// iOS: PRICE_INCREASE. + /// Android: SUBSCRIPTION_PRICE_CHANGE_CONFIRMED. + SubscriptionPriceChange('subscription-price-change'), + /// User upgraded, downgraded, or crossgraded their plan. + /// iOS: DID_CHANGE_RENEWAL_PREF. + /// Android: SUBSCRIPTION_DEFERRED / SUBSCRIPTION_PRODUCT_CHANGED. + SubscriptionProductChanged('subscription-product-changed'), + /// Subscription paused (Android only feature). + /// Android: SUBSCRIPTION_PAUSED. + SubscriptionPaused('subscription-paused'), + /// Paused subscription resumed (Android only feature). + /// Android: SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED / SUBSCRIPTION_RECOVERED from pause. + SubscriptionResumed('subscription-resumed'), + /// Refund issued for a one-time purchase or subscription period. + /// iOS: REFUND. + /// Android: ONE_TIME_PRODUCT_REFUNDED / VOIDED_PURCHASE. + PurchaseRefunded('purchase-refunded'), + /// iOS-only: App Store requests a consumption status report for a refund decision. + /// Servers should respond via the StoreKit consumption API. + PurchaseConsumptionRequest('purchase-consumption-request'), + /// Sandbox or test notification fired by the store for diagnostic purposes. + /// Useful for verifying webhook plumbing without a live transaction. + TestNotification('test-notification'); + + const WebhookEventType(this.value); + final String value; + + factory WebhookEventType.fromJson(String value) { + final normalized = value.toLowerCase().replaceAll('_', '-'); + switch (normalized) { + case 'subscription-started': + return WebhookEventType.SubscriptionStarted; + case 'subscription-renewed': + return WebhookEventType.SubscriptionRenewed; + case 'subscription-expired': + return WebhookEventType.SubscriptionExpired; + case 'subscription-in-grace-period': + return WebhookEventType.SubscriptionInGracePeriod; + case 'subscription-in-billing-retry': + return WebhookEventType.SubscriptionInBillingRetry; + case 'subscription-recovered': + return WebhookEventType.SubscriptionRecovered; + case 'subscription-canceled': + return WebhookEventType.SubscriptionCanceled; + case 'subscription-uncanceled': + return WebhookEventType.SubscriptionUncanceled; + case 'subscription-revoked': + return WebhookEventType.SubscriptionRevoked; + case 'subscription-price-change': + return WebhookEventType.SubscriptionPriceChange; + case 'subscription-product-changed': + return WebhookEventType.SubscriptionProductChanged; + case 'subscription-paused': + return WebhookEventType.SubscriptionPaused; + case 'subscription-resumed': + return WebhookEventType.SubscriptionResumed; + case 'purchase-refunded': + return WebhookEventType.PurchaseRefunded; + case 'purchase-consumption-request': + return WebhookEventType.PurchaseConsumptionRequest; + case 'test-notification': + return WebhookEventType.TestNotification; + } + throw ArgumentError('Unknown WebhookEventType value: $value'); + } + + String toJson() => value; +} + // MARK: - Interfaces abstract class ProductCommon { @@ -3689,6 +3915,112 @@ class VerifyPurchaseWithProviderResult { typedef VoidResult = void; +class WebhookEvent { + const WebhookEvent({ + this.cancellationReason, + this.currency, + required this.environment, + this.expiresAt, + required this.id, + required this.occurredAt, + required this.platform, + this.priceAmountMicros, + this.productId, + required this.projectId, + required this.purchaseToken, + this.rawSignedPayload, + required this.receivedAt, + this.renewsAt, + required this.source, + this.subscriptionState, + required this.type, + }); + + /// Reason for cancellation, when applicable. + final WebhookCancellationReason? cancellationReason; + /// Localized currency code (ISO 4217) at event time, when available. + final String? currency; + final WebhookEventEnvironment environment; + /// When the current subscription period ends. Epoch milliseconds. + final double? expiresAt; + /// 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. + final String id; + /// Time the underlying event occurred at the store. Epoch milliseconds. + final double occurredAt; + final IapPlatform platform; + /// 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. + final double? priceAmountMicros; + /// Product the event pertains to. May be null for account-level events. + final String? productId; + /// kit project that owns the subscription / purchase this event refers to. + final String projectId; + /// Cross-platform purchase identity used to correlate this event with an existing + /// purchase record. iOS: `originalTransactionId`. Android: `purchaseToken`. + final String purchaseToken; + /// Original signed payload from the store. ASN v2 events expose the JWS string; + /// RTDN events expose the base64-decoded Pub/Sub message JSON. Provided so that + /// consumers can independently verify or extract platform-specific fields. kit + /// always validates this payload before emitting the event. + final String? rawSignedPayload; + /// Time kit ingested and normalized this event. Epoch milliseconds. + final double receivedAt; + /// When auto-renewal will charge again. Epoch milliseconds. + final double? renewsAt; + final WebhookEventSource source; + /// Normalized subscription state at the time of event, when the event refers to + /// a subscription. Null for one-time purchase events. + final SubscriptionState? subscriptionState; + final WebhookEventType type; + + factory WebhookEvent.fromJson(Map json) { + return WebhookEvent( + cancellationReason: json['cancellationReason'] != null ? WebhookCancellationReason.fromJson(json['cancellationReason'] as String) : null, + currency: json['currency'] as String?, + environment: WebhookEventEnvironment.fromJson(json['environment'] as String), + expiresAt: (json['expiresAt'] as num?)?.toDouble(), + id: json['id'] as String, + occurredAt: (json['occurredAt'] as num).toDouble(), + platform: IapPlatform.fromJson(json['platform'] as String), + priceAmountMicros: (json['priceAmountMicros'] as num?)?.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 num).toDouble(), + renewsAt: (json['renewsAt'] as num?)?.toDouble(), + source: WebhookEventSource.fromJson(json['source'] as String), + subscriptionState: json['subscriptionState'] != null ? SubscriptionState.fromJson(json['subscriptionState'] as String) : null, + type: WebhookEventType.fromJson(json['type'] as String), + ); + } + + Map toJson() { + return { + '__typename': 'WebhookEvent', + 'cancellationReason': cancellationReason?.toJson(), + 'currency': currency, + 'environment': environment.toJson(), + 'expiresAt': expiresAt, + 'id': id, + 'occurredAt': occurredAt, + 'platform': platform.toJson(), + 'priceAmountMicros': priceAmountMicros, + 'productId': productId, + 'projectId': projectId, + 'purchaseToken': purchaseToken, + 'rawSignedPayload': rawSignedPayload, + 'receivedAt': receivedAt, + 'renewsAt': renewsAt, + 'source': source.toJson(), + 'subscriptionState': subscriptionState?.toJson(), + 'type': type.toJson(), + }; + } +} + // MARK: - Input Objects class AndroidSubscriptionOfferInput { @@ -5074,6 +5406,13 @@ abstract class QueryResolver { VerifyPurchaseGoogleOptions? google, VerifyPurchaseHorizonOptions? horizon, }); + /// 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. + Future> webhookEventsSince({ + required double sinceMs, + int? limit, + }); } /// GraphQL root subscription operations. @@ -5105,6 +5444,14 @@ abstract class SubscriptionResolver { /// Fires when a user selects alternative billing in the User Choice Billing dialog (Android only) /// Only triggered when the user selects alternative billing instead of Google Play billing Future userChoiceBillingAndroid(); + /// Streams normalized webhook events tied to the authenticated client's purchases. + /// Clients only receive events whose `purchaseToken` matches a purchase they own. + /// + /// Transport: kit serves this over WebSocket. SDKs auto-connect when the host app + /// enters foreground and disconnect when it goes to background. Events that fire + /// while the connection is closed are reconciled via `webhookEventsSince` on + /// reconnect or the next foreground entry. + Future webhookEvent(); } // MARK: - Root Operation Helpers @@ -5255,6 +5602,10 @@ typedef QueryValidateReceiptIOSHandler = Future Functio VerifyPurchaseGoogleOptions? google, VerifyPurchaseHorizonOptions? horizon, }); +typedef QueryWebhookEventsSinceHandler = Future> Function({ + required double sinceMs, + int? limit, +}); class QueryHandlers { const QueryHandlers({ @@ -5279,6 +5630,7 @@ class QueryHandlers { this.latestTransactionIOS, this.subscriptionStatusIOS, this.validateReceiptIOS, + this.webhookEventsSince, }); final QueryCanPresentExternalPurchaseNoticeIOSHandler? canPresentExternalPurchaseNoticeIOS; @@ -5302,6 +5654,7 @@ class QueryHandlers { final QueryLatestTransactionIOSHandler? latestTransactionIOS; final QuerySubscriptionStatusIOSHandler? subscriptionStatusIOS; final QueryValidateReceiptIOSHandler? validateReceiptIOS; + final QueryWebhookEventsSinceHandler? webhookEventsSince; } // MARK: - Subscription Helpers @@ -5312,6 +5665,7 @@ typedef SubscriptionPurchaseErrorHandler = Future Function(); typedef SubscriptionPurchaseUpdatedHandler = Future Function(); typedef SubscriptionSubscriptionBillingIssueHandler = Future Function(); typedef SubscriptionUserChoiceBillingAndroidHandler = Future Function(); +typedef SubscriptionWebhookEventHandler = Future Function(); class SubscriptionHandlers { const SubscriptionHandlers({ @@ -5321,6 +5675,7 @@ class SubscriptionHandlers { this.purchaseUpdated, this.subscriptionBillingIssue, this.userChoiceBillingAndroid, + this.webhookEvent, }); final SubscriptionDeveloperProvidedBillingAndroidHandler? developerProvidedBillingAndroid; @@ -5329,4 +5684,5 @@ class SubscriptionHandlers { final SubscriptionPurchaseUpdatedHandler? purchaseUpdated; final SubscriptionSubscriptionBillingIssueHandler? subscriptionBillingIssue; final SubscriptionUserChoiceBillingAndroidHandler? userChoiceBillingAndroid; + final SubscriptionWebhookEventHandler? webhookEvent; } diff --git a/packages/gql/src/generated/types.gd b/packages/gql/src/generated/types.gd index bc96928f..5e9c3ed3 100644 --- a/packages/gql/src/generated/types.gd +++ b/packages/gql/src/generated/types.gd @@ -295,6 +295,72 @@ enum SubscriptionReplacementModeAndroid { KEEP_EXISTING = 6, } +enum SubscriptionState { + ACTIVE = 0, + IN_GRACE_PERIOD = 1, + IN_BILLING_RETRY = 2, + EXPIRED = 3, + REVOKED = 4, + REFUNDED = 5, + PAUSED = 6, + UNKNOWN = 7, +} + +enum WebhookCancellationReason { + USER_CANCELED = 0, + BILLING_ERROR = 1, + PRICE_INCREASE_DECLINED = 2, + PRODUCT_UNAVAILABLE = 3, + REFUNDED = 4, + OTHER = 5, +} + +enum WebhookEventEnvironment { + PRODUCTION = 0, + SANDBOX = 1, + XCODE = 2, +} + +enum WebhookEventSource { + APPLE_APP_STORE_SERVER_NOTIFICATIONS_V2 = 0, + GOOGLE_PLAY_REAL_TIME_DEVELOPER_NOTIFICATIONS = 1, +} + +enum WebhookEventType { + ## Initial purchase or first conversion from a free trial / intro offer. iOS: SUBSCRIBED (initialBuy / resubscribe). Android: SUBSCRIPTION_PURCHASED. + SUBSCRIPTION_STARTED = 0, + ## Auto-renewal succeeded for an existing subscription. iOS: DID_RENEW. Android: SUBSCRIPTION_RENEWED. + SUBSCRIPTION_RENEWED = 1, + ## Subscription reached its expiration without a successful renewal. iOS: EXPIRED. Android: SUBSCRIPTION_EXPIRED. + SUBSCRIPTION_EXPIRED = 2, + ## Billing failed; the subscription is in a grace period during which the user retains entitlement while payment is retried. iOS: DID_FAIL_TO_RENEW (with grace period active). Android: SUBSCRIPTION_IN_GRACE_PERIOD. + SUBSCRIPTION_IN_GRACE_PERIOD = 3, + ## Billing failed and the subscription is in account-hold / billing retry, during which entitlement is paused but the subscription is not yet expired. iOS: DID_FAIL_TO_RENEW (no grace period; billing retry). Android: SUBSCRIPTION_ON_HOLD. + SUBSCRIPTION_IN_BILLING_RETRY = 4, + ## Subscription returned to active state after a billing issue or pause. iOS: DID_RECOVER. Android: SUBSCRIPTION_RECOVERED / SUBSCRIPTION_RESTARTED. + SUBSCRIPTION_RECOVERED = 5, + ## User turned off auto-renew. Access continues until the current period ends. iOS: DID_CHANGE_RENEWAL_STATUS (autoRenew turned off). Android: SUBSCRIPTION_CANCELED. + SUBSCRIPTION_CANCELED = 6, + ## 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). + SUBSCRIPTION_UNCANCELED = 7, + ## Access immediately revoked (family sharing removal, admin action, fraud). iOS: REVOKE. Android: SUBSCRIPTION_REVOKED. + SUBSCRIPTION_REVOKED = 8, + ## A price change is pending or has been confirmed by the user. iOS: PRICE_INCREASE. Android: SUBSCRIPTION_PRICE_CHANGE_CONFIRMED. + SUBSCRIPTION_PRICE_CHANGE = 9, + ## User upgraded, downgraded, or crossgraded their plan. iOS: DID_CHANGE_RENEWAL_PREF. Android: SUBSCRIPTION_DEFERRED / SUBSCRIPTION_PRODUCT_CHANGED. + SUBSCRIPTION_PRODUCT_CHANGED = 10, + ## Subscription paused (Android only feature). Android: SUBSCRIPTION_PAUSED. + SUBSCRIPTION_PAUSED = 11, + ## Paused subscription resumed (Android only feature). Android: SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED / SUBSCRIPTION_RECOVERED from pause. + SUBSCRIPTION_RESUMED = 12, + ## Refund issued for a one-time purchase or subscription period. iOS: REFUND. Android: ONE_TIME_PRODUCT_REFUNDED / VOIDED_PURCHASE. + PURCHASE_REFUNDED = 13, + ## iOS-only: App Store requests a consumption status report for a refund decision. Servers should respond via the StoreKit consumption API. + PURCHASE_CONSUMPTION_REQUEST = 14, + ## Sandbox or test notification fired by the store for diagnostic purposes. Useful for verifying webhook plumbing without a live transaction. + TEST_NOTIFICATION = 15, +} + # ============================================================================ # Types # ============================================================================ @@ -3238,6 +3304,145 @@ class VoidResult: dict["success"] = success return dict +class WebhookEvent: + ## Stable identifier suitable for idempotency. Derived from the source notification + var id: String = "" + var type: WebhookEventType + var source: WebhookEventSource + var platform: IapPlatform + ## kit project that owns the subscription / purchase this event refers to. + var project_id: String = "" + ## Time the underlying event occurred at the store. Epoch milliseconds. + var occurred_at: float = 0.0 + ## Time kit ingested and normalized this event. Epoch milliseconds. + var received_at: float = 0.0 + var environment: WebhookEventEnvironment + ## Cross-platform purchase identity used to correlate this event with an existing + var purchase_token: String = "" + ## Product the event pertains to. May be null for account-level events. + var product_id: Variant = null + ## Normalized subscription state at the time of event, when the event refers to + var subscription_state: SubscriptionState + ## When the current subscription period ends. Epoch milliseconds. + var expires_at: Variant = null + ## When auto-renewal will charge again. Epoch milliseconds. + var renews_at: Variant = null + ## Reason for cancellation, when applicable. + var cancellation_reason: WebhookCancellationReason + ## Localized currency code (ISO 4217) at event time, when available. + var currency: Variant = null + ## Price in micros (1/1,000,000 of the currency unit) at event time, when available. + var price_amount_micros: Variant = null + ## Original signed payload from the store. ASN v2 events expose the JWS string; + var raw_signed_payload: Variant = null + + 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 + + func to_dict() -> Dictionary: + var dict = {} + dict["id"] = id + if WEBHOOK_EVENT_TYPE_VALUES.has(type): + dict["type"] = WEBHOOK_EVENT_TYPE_VALUES[type] + else: + dict["type"] = type + if WEBHOOK_EVENT_SOURCE_VALUES.has(source): + dict["source"] = WEBHOOK_EVENT_SOURCE_VALUES[source] + else: + dict["source"] = source + if IAP_PLATFORM_VALUES.has(platform): + dict["platform"] = IAP_PLATFORM_VALUES[platform] + else: + dict["platform"] = platform + dict["projectId"] = project_id + dict["occurredAt"] = occurred_at + dict["receivedAt"] = received_at + if WEBHOOK_EVENT_ENVIRONMENT_VALUES.has(environment): + dict["environment"] = WEBHOOK_EVENT_ENVIRONMENT_VALUES[environment] + else: + dict["environment"] = environment + dict["purchaseToken"] = purchase_token + if product_id != null: + dict["productId"] = product_id + if SUBSCRIPTION_STATE_VALUES.has(subscription_state): + dict["subscriptionState"] = SUBSCRIPTION_STATE_VALUES[subscription_state] + else: + dict["subscriptionState"] = subscription_state + if expires_at != null: + dict["expiresAt"] = expires_at + if renews_at != null: + dict["renewsAt"] = renews_at + if WEBHOOK_CANCELLATION_REASON_VALUES.has(cancellation_reason): + dict["cancellationReason"] = WEBHOOK_CANCELLATION_REASON_VALUES[cancellation_reason] + else: + dict["cancellationReason"] = cancellation_reason + if currency != null: + dict["currency"] = currency + if price_amount_micros != null: + dict["priceAmountMicros"] = price_amount_micros + if raw_signed_payload != null: + dict["rawSignedPayload"] = raw_signed_payload + return dict + # ============================================================================ # Input Types # ============================================================================ @@ -4569,6 +4774,56 @@ const SUBSCRIPTION_REPLACEMENT_MODE_ANDROID_VALUES = { SubscriptionReplacementModeAndroid.KEEP_EXISTING: "keep-existing" } +const SUBSCRIPTION_STATE_VALUES = { + SubscriptionState.ACTIVE: "active", + SubscriptionState.IN_GRACE_PERIOD: "in-grace-period", + SubscriptionState.IN_BILLING_RETRY: "in-billing-retry", + SubscriptionState.EXPIRED: "expired", + SubscriptionState.REVOKED: "revoked", + SubscriptionState.REFUNDED: "refunded", + SubscriptionState.PAUSED: "paused", + SubscriptionState.UNKNOWN: "unknown" +} + +const WEBHOOK_CANCELLATION_REASON_VALUES = { + WebhookCancellationReason.USER_CANCELED: "user-canceled", + WebhookCancellationReason.BILLING_ERROR: "billing-error", + WebhookCancellationReason.PRICE_INCREASE_DECLINED: "price-increase-declined", + WebhookCancellationReason.PRODUCT_UNAVAILABLE: "product-unavailable", + WebhookCancellationReason.REFUNDED: "refunded", + WebhookCancellationReason.OTHER: "other" +} + +const WEBHOOK_EVENT_ENVIRONMENT_VALUES = { + WebhookEventEnvironment.PRODUCTION: "production", + WebhookEventEnvironment.SANDBOX: "sandbox", + WebhookEventEnvironment.XCODE: "xcode" +} + +const WEBHOOK_EVENT_SOURCE_VALUES = { + WebhookEventSource.APPLE_APP_STORE_SERVER_NOTIFICATIONS_V2: "apple-app-store-server-notifications-v2", + WebhookEventSource.GOOGLE_PLAY_REAL_TIME_DEVELOPER_NOTIFICATIONS: "google-play-real-time-developer-notifications" +} + +const WEBHOOK_EVENT_TYPE_VALUES = { + WebhookEventType.SUBSCRIPTION_STARTED: "subscription-started", + WebhookEventType.SUBSCRIPTION_RENEWED: "subscription-renewed", + WebhookEventType.SUBSCRIPTION_EXPIRED: "subscription-expired", + WebhookEventType.SUBSCRIPTION_IN_GRACE_PERIOD: "subscription-in-grace-period", + WebhookEventType.SUBSCRIPTION_IN_BILLING_RETRY: "subscription-in-billing-retry", + WebhookEventType.SUBSCRIPTION_RECOVERED: "subscription-recovered", + WebhookEventType.SUBSCRIPTION_CANCELED: "subscription-canceled", + WebhookEventType.SUBSCRIPTION_UNCANCELED: "subscription-uncanceled", + WebhookEventType.SUBSCRIPTION_REVOKED: "subscription-revoked", + WebhookEventType.SUBSCRIPTION_PRICE_CHANGE: "subscription-price-change", + WebhookEventType.SUBSCRIPTION_PRODUCT_CHANGED: "subscription-product-changed", + WebhookEventType.SUBSCRIPTION_PAUSED: "subscription-paused", + WebhookEventType.SUBSCRIPTION_RESUMED: "subscription-resumed", + WebhookEventType.PURCHASE_REFUNDED: "purchase-refunded", + WebhookEventType.PURCHASE_CONSUMPTION_REQUEST: "purchase-consumption-request", + WebhookEventType.TEST_NOTIFICATION: "test-notification" +} + # ============================================================================ # Enum Reverse Lookup (string -> enum for deserialization) # ============================================================================ @@ -4787,6 +5042,56 @@ const SUBSCRIPTION_REPLACEMENT_MODE_ANDROID_FROM_STRING = { "keep-existing": SubscriptionReplacementModeAndroid.KEEP_EXISTING } +const SUBSCRIPTION_STATE_FROM_STRING = { + "active": SubscriptionState.ACTIVE, + "in-grace-period": SubscriptionState.IN_GRACE_PERIOD, + "in-billing-retry": SubscriptionState.IN_BILLING_RETRY, + "expired": SubscriptionState.EXPIRED, + "revoked": SubscriptionState.REVOKED, + "refunded": SubscriptionState.REFUNDED, + "paused": SubscriptionState.PAUSED, + "unknown": SubscriptionState.UNKNOWN +} + +const WEBHOOK_CANCELLATION_REASON_FROM_STRING = { + "user-canceled": WebhookCancellationReason.USER_CANCELED, + "billing-error": WebhookCancellationReason.BILLING_ERROR, + "price-increase-declined": WebhookCancellationReason.PRICE_INCREASE_DECLINED, + "product-unavailable": WebhookCancellationReason.PRODUCT_UNAVAILABLE, + "refunded": WebhookCancellationReason.REFUNDED, + "other": WebhookCancellationReason.OTHER +} + +const WEBHOOK_EVENT_ENVIRONMENT_FROM_STRING = { + "production": WebhookEventEnvironment.PRODUCTION, + "sandbox": WebhookEventEnvironment.SANDBOX, + "xcode": WebhookEventEnvironment.XCODE +} + +const WEBHOOK_EVENT_SOURCE_FROM_STRING = { + "apple-app-store-server-notifications-v2": WebhookEventSource.APPLE_APP_STORE_SERVER_NOTIFICATIONS_V2, + "google-play-real-time-developer-notifications": WebhookEventSource.GOOGLE_PLAY_REAL_TIME_DEVELOPER_NOTIFICATIONS +} + +const WEBHOOK_EVENT_TYPE_FROM_STRING = { + "subscription-started": WebhookEventType.SUBSCRIPTION_STARTED, + "subscription-renewed": WebhookEventType.SUBSCRIPTION_RENEWED, + "subscription-expired": WebhookEventType.SUBSCRIPTION_EXPIRED, + "subscription-in-grace-period": WebhookEventType.SUBSCRIPTION_IN_GRACE_PERIOD, + "subscription-in-billing-retry": WebhookEventType.SUBSCRIPTION_IN_BILLING_RETRY, + "subscription-recovered": WebhookEventType.SUBSCRIPTION_RECOVERED, + "subscription-canceled": WebhookEventType.SUBSCRIPTION_CANCELED, + "subscription-uncanceled": WebhookEventType.SUBSCRIPTION_UNCANCELED, + "subscription-revoked": WebhookEventType.SUBSCRIPTION_REVOKED, + "subscription-price-change": WebhookEventType.SUBSCRIPTION_PRICE_CHANGE, + "subscription-product-changed": WebhookEventType.SUBSCRIPTION_PRODUCT_CHANGED, + "subscription-paused": WebhookEventType.SUBSCRIPTION_PAUSED, + "subscription-resumed": WebhookEventType.SUBSCRIPTION_RESUMED, + "purchase-refunded": WebhookEventType.PURCHASE_REFUNDED, + "purchase-consumption-request": WebhookEventType.PURCHASE_CONSUMPTION_REQUEST, + "test-notification": WebhookEventType.TEST_NOTIFICATION +} + # ============================================================================ # Query Types # ============================================================================ @@ -5129,6 +5434,30 @@ class Query: const return_type = "VerifyPurchaseResultIOS" const is_array = false + ## Replay missed webhook events for the authenticated client since the given + class webhookEventsSinceField: + const name = "webhookEventsSince" + const snake_name = "webhook_events_since" + class Args: + var since_ms: float + var limit: int + + static func from_dict(data: Dictionary) -> Args: + var obj = Args.new() + if data.has("sinceMs") and data["sinceMs"] != null: + obj.since_ms = data["sinceMs"] + if data.has("limit") and data["limit"] != null: + obj.limit = data["limit"] + return obj + + func to_dict() -> Dictionary: + var dict = {} + dict["sinceMs"] = since_ms + dict["limit"] = limit + return dict + const return_type = "WebhookEvent" + const is_array = true + # ============================================================================ # Mutation Types @@ -5696,6 +6025,13 @@ static func validate_receipt_ios_args(options: VerifyPurchaseProps) -> Dictionar args["options"] = options return args +## Replay missed webhook events for the authenticated client since the given +static func webhook_events_since_args(since_ms: float, limit: int) -> Dictionary: + var args = {} + args["sinceMs"] = since_ms + args["limit"] = limit + return args + # Mutation API helpers ## Initialize the store connection. Call before any IAP API. diff --git a/packages/gql/src/generated/types.ts b/packages/gql/src/generated/types.ts index 48f0103b..cc3408f3 100644 --- a/packages/gql/src/generated/types.ts +++ b/packages/gql/src/generated/types.ts @@ -1406,6 +1406,12 @@ export interface Query { * @deprecated Use verifyPurchase */ validateReceiptIOS: Promise; + /** + * 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[]; } @@ -1434,6 +1440,11 @@ export type QuerySubscriptionStatusIosArgs = string; export type QueryValidateReceiptIosArgs = VerifyPurchaseProps; +export interface QueryWebhookEventsSinceArgs { + limit?: (number | null); + sinceMs: number; +} + export interface RefundResultIOS { message?: (string | null); status: string; @@ -1751,6 +1762,16 @@ export interface Subscription { * Only triggered when the user selects alternative billing instead of Google Play billing */ userChoiceBillingAndroid: UserChoiceBillingDetails; + /** + * Streams normalized webhook events tied to the authenticated client's purchases. + * Clients only receive events whose `purchaseToken` matches a purchase they own. + * + * Transport: kit serves this over WebSocket. SDKs auto-connect when the host app + * enters foreground and disconnect when it goes to background. Events that fire + * while the connection is closed are reconciled via `webhookEventsSince` on + * reconnect or the next foreground entry. + */ + webhookEvent: WebhookEvent; } @@ -1896,6 +1917,8 @@ export interface SubscriptionProductReplacementParamsAndroid { */ export type SubscriptionReplacementModeAndroid = 'unknown-replacement-mode' | 'with-time-proration' | 'charge-prorated-price' | 'charge-full-price' | 'without-proration' | 'deferred' | 'keep-existing'; +export type SubscriptionState = 'active' | 'expired' | 'in-billing-retry' | 'in-grace-period' | 'paused' | 'refunded' | 'revoked' | 'unknown'; + export interface SubscriptionStatusIOS { renewalInfo?: (RenewalInfoIOS | null); state: string; @@ -2057,6 +2080,65 @@ export interface VerifyPurchaseWithProviderResult { export type VoidResult = void; +export type WebhookCancellationReason = 'billing-error' | 'other' | 'price-increase-declined' | 'product-unavailable' | 'refunded' | 'user-canceled'; + +export interface WebhookEvent { + /** Reason for cancellation, when applicable. */ + cancellationReason?: (WebhookCancellationReason | null); + /** Localized currency code (ISO 4217) at event time, when available. */ + currency?: (string | null); + environment: WebhookEventEnvironment; + /** When the current subscription period ends. Epoch milliseconds. */ + expiresAt?: (number | null); + /** + * 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. + */ + id: string; + /** Time the underlying event occurred at the store. Epoch milliseconds. */ + occurredAt: number; + 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. + */ + priceAmountMicros?: (number | null); + /** Product the event pertains to. May be null for account-level events. */ + productId?: (string | null); + /** kit project that owns the subscription / purchase this event refers to. */ + projectId: string; + /** + * Cross-platform purchase identity used to correlate this event with an existing + * purchase record. iOS: `originalTransactionId`. Android: `purchaseToken`. + */ + purchaseToken: string; + /** + * Original signed payload from the store. ASN v2 events expose the JWS string; + * RTDN events expose the base64-decoded Pub/Sub message JSON. Provided so that + * consumers can independently verify or extract platform-specific fields. kit + * always validates this payload before emitting the event. + */ + rawSignedPayload?: (string | null); + /** Time kit ingested and normalized this event. Epoch milliseconds. */ + receivedAt: number; + /** When auto-renewal will charge again. Epoch milliseconds. */ + renewsAt?: (number | null); + source: WebhookEventSource; + /** + * Normalized subscription state at the time of event, when the event refers to + * a subscription. Null for one-time purchase events. + */ + subscriptionState?: (SubscriptionState | null); + type: WebhookEventType; +} + +export type WebhookEventEnvironment = 'production' | 'sandbox' | 'xcode'; + +export type WebhookEventSource = 'apple-app-store-server-notifications-v2' | 'google-play-real-time-developer-notifications'; + +export type WebhookEventType = 'purchase-consumption-request' | 'purchase-refunded' | 'subscription-canceled' | 'subscription-expired' | 'subscription-in-billing-retry' | 'subscription-in-grace-period' | 'subscription-paused' | 'subscription-price-change' | 'subscription-product-changed' | 'subscription-recovered' | 'subscription-renewed' | 'subscription-resumed' | 'subscription-revoked' | 'subscription-started' | 'subscription-uncanceled' | 'test-notification'; + /** * Win-back offer input for iOS 18+ (StoreKit 2) * Win-back offers are used to re-engage churned subscribers. @@ -2090,6 +2172,7 @@ export type QueryArgsMap = { latestTransactionIOS: QueryLatestTransactionIosArgs; subscriptionStatusIOS: QuerySubscriptionStatusIosArgs; validateReceiptIOS: QueryValidateReceiptIosArgs; + webhookEventsSince: QueryWebhookEventsSinceArgs; }; export type QueryField = @@ -2154,6 +2237,7 @@ export type SubscriptionArgsMap = { purchaseUpdated: never; subscriptionBillingIssue: never; userChoiceBillingAndroid: never; + webhookEvent: never; }; export type SubscriptionField = diff --git a/packages/gql/src/webhook.graphql b/packages/gql/src/webhook.graphql new file mode 100644 index 00000000..13692a2e --- /dev/null +++ b/packages/gql/src/webhook.graphql @@ -0,0 +1,236 @@ +# Server-side lifecycle webhook events normalized across stores. +# +# kit (kit.openiap.dev) ingests Apple App Store Server Notifications v2 (ASN v2) +# and Google Play Real-Time Developer Notifications (RTDN), normalizes them into +# a unified WebhookEvent shape, and streams them to authenticated clients via the +# `webhookEvent` GraphQL Subscription. +# +# Design goals: +# - Single event shape regardless of which store fired the notification. +# - Idempotency: every normalized event has a stable `id` so consumers can dedupe. +# - Escape hatch: the raw signed payload from the store is preserved in +# `rawSignedPayload` for consumers that need platform-specific fields. + +# What kind of lifecycle change occurred. Mapped from ASN v2 notificationType +# and RTDN notificationType to a unified vocabulary. +enum WebhookEventType { + """ + Initial purchase or first conversion from a free trial / intro offer. + iOS: SUBSCRIBED (initialBuy / resubscribe). + Android: SUBSCRIPTION_PURCHASED. + """ + SubscriptionStarted + """ + Auto-renewal succeeded for an existing subscription. + iOS: DID_RENEW. + Android: SUBSCRIPTION_RENEWED. + """ + SubscriptionRenewed + """ + Subscription reached its expiration without a successful renewal. + iOS: EXPIRED. + Android: SUBSCRIPTION_EXPIRED. + """ + SubscriptionExpired + """ + Billing failed; the subscription is in a grace period during which the user + retains entitlement while payment is retried. + iOS: DID_FAIL_TO_RENEW (with grace period active). + Android: SUBSCRIPTION_IN_GRACE_PERIOD. + """ + SubscriptionInGracePeriod + """ + Billing failed and the subscription is in account-hold / billing retry, + during which entitlement is paused but the subscription is not yet expired. + iOS: DID_FAIL_TO_RENEW (no grace period; billing retry). + Android: SUBSCRIPTION_ON_HOLD. + """ + SubscriptionInBillingRetry + """ + Subscription returned to active state after a billing issue or pause. + iOS: DID_RECOVER. + Android: SUBSCRIPTION_RECOVERED / SUBSCRIPTION_RESTARTED. + """ + SubscriptionRecovered + """ + User turned off auto-renew. Access continues until the current period ends. + iOS: DID_CHANGE_RENEWAL_STATUS (autoRenew turned off). + Android: SUBSCRIPTION_CANCELED. + """ + SubscriptionCanceled + """ + 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 + """ + Access immediately revoked (family sharing removal, admin action, fraud). + iOS: REVOKE. + Android: SUBSCRIPTION_REVOKED. + """ + SubscriptionRevoked + """ + A price change is pending or has been confirmed by the user. + iOS: PRICE_INCREASE. + Android: SUBSCRIPTION_PRICE_CHANGE_CONFIRMED. + """ + SubscriptionPriceChange + """ + User upgraded, downgraded, or crossgraded their plan. + iOS: DID_CHANGE_RENEWAL_PREF. + Android: SUBSCRIPTION_DEFERRED / SUBSCRIPTION_PRODUCT_CHANGED. + """ + SubscriptionProductChanged + """ + Subscription paused (Android only feature). + Android: SUBSCRIPTION_PAUSED. + """ + SubscriptionPaused + """ + Paused subscription resumed (Android only feature). + Android: SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED / SUBSCRIPTION_RECOVERED from pause. + """ + SubscriptionResumed + """ + Refund issued for a one-time purchase or subscription period. + iOS: REFUND. + Android: ONE_TIME_PRODUCT_REFUNDED / VOIDED_PURCHASE. + """ + PurchaseRefunded + """ + iOS-only: App Store requests a consumption status report for a refund decision. + Servers should respond via the StoreKit consumption API. + """ + PurchaseConsumptionRequest + """ + Sandbox or test notification fired by the store for diagnostic purposes. + Useful for verifying webhook plumbing without a live transaction. + """ + TestNotification +} + +# Which store-side notification system produced this event. +enum WebhookEventSource { + AppleAppStoreServerNotificationsV2 + GooglePlayRealTimeDeveloperNotifications +} + +# Environment of the source notification, as reported by the store. +enum WebhookEventEnvironment { + Production + Sandbox + Xcode +} + +# Normalized cross-store subscription state derived from the webhook event. +enum SubscriptionState { + Active + InGracePeriod + InBillingRetry + Expired + Revoked + Refunded + Paused + Unknown +} + +# Why a subscription was canceled, when applicable. +enum WebhookCancellationReason { + UserCanceled + BillingError + PriceIncreaseDeclined + ProductUnavailable + Refunded + Other +} + +# A normalized lifecycle event delivered to clients via Subscription.webhookEvent. +type WebhookEvent { + """ + 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. + """ + id: ID! + type: WebhookEventType! + source: WebhookEventSource! + platform: IapPlatform! + """ + kit project that owns the subscription / purchase this event refers to. + """ + projectId: ID! + """ + Time the underlying event occurred at the store. Epoch milliseconds. + """ + occurredAt: Float! + """ + Time kit ingested and normalized this event. Epoch milliseconds. + """ + receivedAt: Float! + environment: WebhookEventEnvironment! + """ + Cross-platform purchase identity used to correlate this event with an existing + purchase record. iOS: `originalTransactionId`. Android: `purchaseToken`. + """ + purchaseToken: String! + """ + Product the event pertains to. May be null for account-level events. + """ + productId: String + """ + Normalized subscription state at the time of event, when the event refers to + a subscription. Null for one-time purchase events. + """ + subscriptionState: SubscriptionState + """ + When the current subscription period ends. Epoch milliseconds. + """ + expiresAt: Float + """ + When auto-renewal will charge again. Epoch milliseconds. + """ + renewsAt: Float + """ + Reason for cancellation, when applicable. + """ + cancellationReason: WebhookCancellationReason + """ + Localized currency code (ISO 4217) at event time, when available. + """ + currency: String + """ + 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. + """ + priceAmountMicros: Float + """ + Original signed payload from the store. ASN v2 events expose the JWS string; + RTDN events expose the base64-decoded Pub/Sub message JSON. Provided so that + consumers can independently verify or extract platform-specific fields. kit + always validates this payload before emitting the event. + """ + rawSignedPayload: String +} + +extend type Subscription { + """ + Streams normalized webhook events tied to the authenticated client's purchases. + Clients only receive events whose `purchaseToken` matches a purchase they own. + + Transport: kit serves this over WebSocket. SDKs auto-connect when the host app + enters foreground and disconnect when it goes to background. Events that fire + while the connection is closed are reconciled via `webhookEventsSince` on + reconnect or the next foreground entry. + """ + webhookEvent: WebhookEvent! +} + +extend type Query { + """ + 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(sinceMs: Float!, limit: Int): [WebhookEvent!]! +}