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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions knowledge/external/webhook-mapping.md
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) |
Comment on lines +15 to +20

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

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

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

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

Also applies to: 25-31

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

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

| `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` |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

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

💡 Result:

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

Citations:


Fix priceAmountMicros conversion to account for nanos fractional precision.

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

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

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

| `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.
84 changes: 84 additions & 0 deletions libraries/expo-iap/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Query.webhookEventsSince should return a Promise

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

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

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

}


Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}


Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -2090,6 +2172,7 @@ export type QueryArgsMap = {
latestTransactionIOS: QueryLatestTransactionIosArgs;
subscriptionStatusIOS: QuerySubscriptionStatusIosArgs;
validateReceiptIOS: QueryValidateReceiptIosArgs;
webhookEventsSince: QueryWebhookEventsSinceArgs;
};

export type QueryField<K extends keyof Query> =
Expand Down Expand Up @@ -2154,6 +2237,7 @@ export type SubscriptionArgsMap = {
purchaseUpdated: never;
subscriptionBillingIssue: never;
userChoiceBillingAndroid: never;
webhookEvent: never;
};

export type SubscriptionField<K extends keyof Subscription> =
Expand Down
Loading
Loading