Skip to content

Instantly share code, notes, and snippets.

@bryanmylee
Created December 20, 2025 09:43
Show Gist options
  • Select an option

  • Save bryanmylee/95dd9be99659c71aa01654bce18f0209 to your computer and use it in GitHub Desktop.

Select an option

Save bryanmylee/95dd9be99659c71aa01654bce18f0209 to your computer and use it in GitHub Desktop.
Valibot schema for RevenueCat Webhook event
import * as v from "valibot";
export const RCEventTypeSchema = v.enum({
TEST: "TEST",
INITIAL_PURCHASE: "INITIAL_PURCHASE",
RENEWAL: "RENEWAL",
CANCELLATION: "CANCELLATION",
UNCANCELLATION: "UNCANCELLATION",
NON_RENEWING_PURCHASE: "NON_RENEWING_PURCHASE",
SUBSCRIPTION_PAUSED: "SUBSCRIPTION_PAUSED",
EXPIRATION: "EXPIRATION",
BILLING_ISSUE: "BILLING_ISSUE",
PRODUCT_CHANGE: "PRODUCT_CHANGE",
TRANSFER: "TRANSFER",
SUBSCRIPTION_EXTENDED: "SUBSCRIPTION_EXTENDED",
TEMPORARY_ENTITLEMENT_GRANT: "TEMPORARY_ENTITLEMENT_GRANT",
REFUND_REVERSED: "REFUND_REVERSED",
INVOICE_ISSUANCE: "INVOICE_ISSUANCE",
VIRTUAL_CURRENCY_TRANSACTION: "VIRTUAL_CURRENCY_TRANSACTION",
EXPERIMENT_ENROLLMENT: "EXPERIMENT_ENROLLMENT",
SUBSCRIBER_ALIAS: "SUBSCRIBER_ALIAS",
});
export type RCEventType = v.InferInput<typeof RCEventTypeSchema>;
/*
* Helpers.
*/
const RCTimestampSchema = v.pipe(v.number(), v.integer());
const RCSubscriberAttributeSchema = v.object({
updated_at_ms: RCTimestampSchema,
value: v.string(),
});
const RCExperimentSchema = v.object({
experiment_id: v.string(),
experiment_variant: v.string(),
/**
* Measured in milliseconds since Unix epoch
*/
enrolled_at_ms: RCTimestampSchema,
});
const RCCommonEventFieldSchema = v.object({
id: v.string(),
/**
* Unique identifier of the app the event is associated with. Corresponds to
* an app within a project. This value can be found in the app's
* configuration page in project settings.
*/
app_id: v.string(),
/**
* The time that the event was generated. Does not necessarily coincide with
* when the action that triggered the event occurred (purchased, cancelled,
* etc).
*/
event_timestamp_ms: RCTimestampSchema,
/**
* Last seen app user id of the subscriber.
*/
app_user_id: v.string(),
/**
* The first app user id used by the subscriber.
*/
original_app_user_id: v.string(),
/**
* All app user ids ever used by the subscriber.
*/
aliases: v.array(v.string()),
/**
* Map of attribute names to attribute objects.
*/
subscriber_attributes: v.record(v.string(), RCSubscriberAttributeSchema),
/**
* The experiments that the subscriber was enrolled in, if any.
*/
experiments: v.array(RCExperimentSchema),
});
const RCSubscriptionPeriodTypeSchema = v.enum({
/**
* Free trials.
*/
TRIAL: "TRIAL",
/**
* Introductory pricing.
*/
INTRO: "INTRO",
/**
* Standard subscription.
*/
NORMAL: "NORMAL",
/**
* Subscription granted throgh RevenueCat.
*/
PROMOTIONAL: "PROMOTIONAL",
/**
* Play Store prepaid transactions.
*/
PREPAID: "PREPAID",
});
const RCStoreSchema = v.enum({
AMAZON: "AMAZON",
APP_STORE: "APP_STORE",
MAC_APP_STORE: "MAC_APP_STORE",
PADDLE: "PADDLE",
PLAY_STORE: "PLAY_STORE",
PROMOTIONAL: "PROMOTIONAL",
RC_BILLING: "RC_BILLING",
ROKU: "ROKU",
STRIPE: "STRIPE",
TEST_STORE: "TEST_STORE",
});
const RCEnvironmentSchema = v.enum({
SANDBOX: "SANDBOX",
PRODUCTION: "PRODUCTION",
});
const RCCancelExpireReasonSchema = v.enum({
UNSUBSCRIBE: "UNSUBSCRIBE",
BILLING_ERROR: "BILLING_ERROR",
DEVELOPER_INITIATED: "DEVELOPER_INITIATED",
PRICE_INCREASE: "PRICE_INCREASE",
CUSTOMER_SUPPORT: "CUSTOMER_SUPPORT",
UNKNOWN: "UNKNOWN",
});
const RCPurchaseEnvironmentSchema = v.enum({
in_app_purchase: "in_app_purchase",
admin_api: "admin_api",
});
const RCVirtualCurrencyAdjustment = v.object({
amount: v.pipe(v.number(), v.integer()),
currency: v.object({
code: v.string(),
name: v.string(),
description: v.string(),
}),
});
const RCSubscriptionLifecycleFieldSchema = v.object({
...RCCommonEventFieldSchema.entries,
/**
* Product identifier of the subscription. Please note: For Google Play
* products set up in RevenueCat after February 2023, this identifier has the
* format `<subscription_id>:<base_plan_id>`.
*/
product_id: v.string(),
/**
* Entitlement identifiers of the subscription. It can be `NULL` if the
* `product_id` is not mapped to any entitlements.
*/
entitlement_ids: v.nullable(v.array(v.string())),
/**
* Period type of the transaction.
*/
period_type: RCSubscriptionPeriodTypeSchema,
/**
* Time when the transaction was purchased. Measured in milliseconds since
* Unix epoch.
*/
purchased_at_ms: RCTimestampSchema,
/**
* Expiration of the transaction. Measured in milliseconds since Unix epoch.
* Use this field to determine if a subscription is still active. It can be
* `NULL` for non-subscription purchases.
*/
expiration_at_ms: v.nullable(RCTimestampSchema),
/**
* The time when an Android subscription would resume after being paused.
* Measured in milliseconds since Unix epoch. Only available for Play Store
* subscriptions and `SUBSCRIPTION_PAUSED` events.
*/
auto_resume_at_ms: v.undefinedable(RCTimestampSchema),
/**
* Store the subscription belongs to.
*/
store: RCStoreSchema,
/**
* Store environment.
*/
environment: RCEnvironmentSchema,
/**
* Not available for apps using legacy entitlements. The identifier for the
* offering that was presented to the user during their initial purchase. Can
* be `NULL` if the purchase was made using `purchaseProduct` instead of
* `purchasePackage` or if the purchase was made outside of your app or
* before you integrated RevenueCat.
*/
presented_offering_id: v.nullable(v.string()),
/**
* The USD price of the transaction. Can be `NULL` if the price is unknown,
* and `0` for free trials. Can be negative for refunds.
*/
price: v.nullable(v.number()),
/**
* The ISO 4217 currency code that the product was purchased in. Can be NULL
* if the currency is unknown.
*/
currency: v.nullable(v.string()),
/**
* The price of the transaction in the currency the product was purchased in.
* Can be `NULL` if the price is unknown, and `0` for free trials. Can be
* negative for refunds.
*/
price_in_purchased_currency: v.nullable(v.number()),
/**
* The estimated percentage of the transaction price that was deducted for
* taxes (varies by country and store). Can be `NULL` if the tax percentage is
* unknown.
*/
tax_percentage: v.nullable(v.number()),
/**
* The estimated percentage of the transaction price that was deducted as a
* store commission / processing fee. Can be `NULL` if the commission
* percentage is unknown.
*/
commission_percentage: v.nullable(v.number()),
/**
* Transaction identifier from Apple/Amazon/Google/Stripe.
*/
transaction_id: v.string(),
/**
* `transaction_id` of the original transaction in the subscription from
* Apple/Amazon/Google/Stripe.
*/
original_transaction_id: v.string(),
/**
* Indicates if the user made this purchase or if it was shared to them via
* Family Sharing. Always false for non-Apple purchases.
*/
is_family_share: v.boolean(),
/**
* The ISO 3166 country code that the product was purchased in. The
* two-letter country code (e.g., US, GB, CA) of the app user's location
* (this country code is derived from the last seen request from the SDK for
* the subscriber.)
*/
country_code: v.string(),
/**
* Not available when type is set to `SUBSCRIBER_ALIAS` or `TRANSFER`. The
* code or identifier that the customer used to redeem the transaction.
* Available for App Store and Play Store.
*
* For App Store this property corresponds to the `offerIdentifier`.
*
* For Play Store this corresponds to the `promotionCode`. Can be null if no
* offer code was used for this product.
*/
offer_code: v.string(),
/**
* The number of renewals that this subscription has already gone through.
* Always starts at 1. Trial conversions are counted as renewals.
* `is_trial_conversion` is used to signify whether a transaction was a trial
* conversion.
*/
renewal_number: v.pipe(v.number(), v.integer()),
});
/*
* Event body.
*/
const RCBillingIssueBodySchema = v.object({
type: v.literal(RCEventTypeSchema.enum.BILLING_ISSUE),
...RCSubscriptionLifecycleFieldSchema.entries,
/**
* Only available for `BILLING_ISSUE` events. The time that the grace period
* for the subscription would expire. Measured in milliseconds since Unix
* epoch. Use this field to determine if the user is currently in a grace
* period. It can be `NULL` if the subscription does not have a grace period.
*/
grace_period_expiration_at_ms: RCTimestampSchema,
});
const RCRenewalBodySchema = v.object({
type: v.literal(RCEventTypeSchema.enum.RENEWAL),
...RCSubscriptionLifecycleFieldSchema.entries,
/**
* Only available for `RENEWAL` events. Whether the previous transaction was a
* free trial or not.
*/
is_trial_conversion: v.boolean(),
});
const RCCancellationBodySchema = v.object({
type: v.literal(RCEventTypeSchema.enum.CANCELLATION),
...RCSubscriptionLifecycleFieldSchema.entries,
/**
* Only available for `CANCELLATION` events.
*/
cancel_reason: RCCancelExpireReasonSchema,
});
const RCExpirationBodySchema = v.object({
type: v.literal(RCEventTypeSchema.enum.EXPIRATION),
...RCSubscriptionLifecycleFieldSchema.entries,
/**
* Only available for `EXPIRATION` events.
*/
expiration_reason: RCCancelExpireReasonSchema,
});
const RCProductChangeBodySchema = v.object({
type: v.literal(RCEventTypeSchema.enum.PRODUCT_CHANGE),
...RCSubscriptionLifecycleFieldSchema.entries,
/**
* Product identifier of the new product the subscriber has switched to. Only
* available in `PRODUCT_CHANGE` events for Play Store subscriptions with the
* DEFERRED replacement mode and App Store subscriptions
*/
new_product_id: v.undefinedable(v.string()),
});
const RCTransferBodySchema = v.object({
type: v.literal(RCEventTypeSchema.enum.TRANSFER),
...v.omit(RCSubscriptionLifecycleFieldSchema, [
"offer_code",
"app_user_id",
"original_app_user_id",
]).entries,
/**
* This field is only available when type is set to `TRANSFER`. App User ID(s)
* that transactions and entitlements are being taken from, and granted to
* `transferred_to`.
*/
transferred_from: v.array(v.string()),
/**
* This field is only available when type is set to `TRANSFER`. App User ID(s)
* that are receiving the transactions and entitlements taken from
* `transferred_from`.
*/
transferred_to: v.array(v.string()),
});
const RCSubscriberAliasBodySchema = v.object({
type: v.literal(RCEventTypeSchema.enum.SUBSCRIBER_ALIAS),
...v.omit(RCSubscriptionLifecycleFieldSchema, ["offer_code"]).entries,
});
const RCVirtualCurrencyTransactionBodySchema = v.object({
type: v.literal(RCEventTypeSchema.enum.VIRTUAL_CURRENCY_TRANSACTION),
...RCCommonEventFieldSchema.entries,
adjustments: v.array(RCVirtualCurrencyAdjustment),
product_display_name: v.string(),
purchase_environment: RCEnvironmentSchema,
source: RCPurchaseEnvironmentSchema,
virtual_currency_transaction_id: v.string(),
});
const RCNormalSubscriptionEventTypeSchema = v.union([
v.literal(RCEventTypeSchema.enum.EXPERIMENT_ENROLLMENT),
v.literal(RCEventTypeSchema.enum.INITIAL_PURCHASE),
v.literal(RCEventTypeSchema.enum.INVOICE_ISSUANCE),
v.literal(RCEventTypeSchema.enum.NON_RENEWING_PURCHASE),
v.literal(RCEventTypeSchema.enum.REFUND_REVERSED),
v.literal(RCEventTypeSchema.enum.SUBSCRIPTION_EXTENDED),
v.literal(RCEventTypeSchema.enum.SUBSCRIPTION_PAUSED),
v.literal(RCEventTypeSchema.enum.TEMPORARY_ENTITLEMENT_GRANT),
v.literal(RCEventTypeSchema.enum.TEST),
v.literal(RCEventTypeSchema.enum.UNCANCELLATION),
]);
export type RCNormalSubscriptionEventType = v.InferInput<
typeof RCNormalSubscriptionEventTypeSchema
>;
const RCNormalSubscriptionLifecycleBodySchema = v.object({
type: RCNormalSubscriptionEventTypeSchema,
...RCSubscriptionLifecycleFieldSchema.entries,
});
export type RCNormalSubscriptionLifecycleBody = v.InferInput<
typeof RCNormalSubscriptionLifecycleBodySchema
>;
export const RCEventBodySchema = v.variant("type", [
RCNormalSubscriptionLifecycleBodySchema,
RCBillingIssueBodySchema,
RCRenewalBodySchema,
RCCancellationBodySchema,
RCExpirationBodySchema,
RCProductChangeBodySchema,
RCTransferBodySchema,
RCSubscriberAliasBodySchema,
RCVirtualCurrencyTransactionBodySchema,
]);
export type RCEventBody = v.InferInput<typeof RCEventBodySchema>;
export const RCEventSchema = v.object({
api_version: v.string(),
event: RCEventBodySchema,
});
export type RCEvent = v.InferInput<typeof RCEventSchema>;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment