-
-
Notifications
You must be signed in to change notification settings - Fork 11
feat(gql): add webhook event spec for ASN v2 / RTDN normalization #123
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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` | | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The term "millicents" is ambiguous and potentially misleading. Apple's documentation for
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🌐 Web query:
💡 Result: In purchases.subscriptionsv2.get, recurringPrice is a Money object with fields: units (whole currency units, as a string) and nanos (the fractional part in nano = 10^-9 currency units) plus currencyCode. Example shape from the API: recurringPrice: { "units": "12", "nanos": 990000000, "currencyCode": "USD" }. To convert units+nanos to micros (1,000,000 micro-units per 1 currency unit): - totalMicros = (units * 1_000_000) + (nanos / 1000) - because nanos is in 10^-9 units, nanos / 1000 gives nano-units expressed in 10^-6 (micro) units. Practical notes: - Ensure you parse units as a number (it is a string in the Money JSON). - Use integer math; nanos may be negative (Money spec allows negative nanos when units is negative or zero). Citations:
Fix The mapping currently uses only 🤖 Prompt for AI Agents |
||
| | `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. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1406,6 +1406,12 @@ export interface Query { | |
| * @deprecated Use verifyPurchase | ||
| */ | ||
| validateReceiptIOS: Promise<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. | ||
| */ | ||
| webhookEventsSince: WebhookEvent[]; | ||
|
Comment on lines
+1409
to
+1414
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Line 1414 diverges from the rest of the Suggested fix- webhookEventsSince: WebhookEvent[];
+ webhookEventsSince: Promise<WebhookEvent[]>;🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
|
|
||
|
|
@@ -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<K extends keyof Query> = | ||
|
|
@@ -2154,6 +2237,7 @@ export type SubscriptionArgsMap = { | |
| purchaseUpdated: never; | ||
| subscriptionBillingIssue: never; | ||
| userChoiceBillingAndroid: never; | ||
| webhookEvent: never; | ||
| }; | ||
|
|
||
| export type SubscriptionField<K extends keyof Subscription> = | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
RTDN notification codes are incorrect/stale in the lifecycle table.
SUBSCRIPTION_PURCHASEDandSUBSCRIPTION_RECOVEREDnumeric codes are swapped in the table/footnote, andSubscriptionProductChangednotes “no fixed code” even though RTDN documents a fixed code for item changes. Since this file is SSOT for the receiver PR, this can misroute normalization logic.Also applies to: 25-31
🤖 Prompt for AI Agents