← All @molecule/* packages · App templates
@molecule/api-payments-revenuecatProvider bond · payments · API (Node) · v1.0.1 · Apache-2.0
RevenueCat payment provider for molecule.dev.
npm install @molecule/api-payments-revenuecatnpm · Source on GitHub · Implements @molecule/api-payments
@molecule/api-payments-revenuecat is a provider bond on the API (Node) side: it implements the payments core interface (@molecule/api-payments) with a concrete vendor or library behind it.
Your code calls the core; you wire this provider once at startup. Swapping vendors later is one line in that wiring, not a rewrite.
import { bond } from '@molecule/api-bond'
import { paymentProvider } from '@molecule/api-payments-revenuecat'
bond('payments', paymentProvider)Works with: @molecule/api-bond, @molecule/api-config, @molecule/api-secrets
Secrets: REVENUECAT_SECRET_API_KEY, REVENUECAT_WEBHOOK_AUTHORIZATION (optional), REVENUECAT_WEBHOOK_SIGNING_SECRET (optional)
Auto-generated, AI-first package reference for the molecule.dev ecosystem. It is written to be read by coding agents as much as by people, and is generated from this package's source — edit
src/index.tsJSDoc, not this file.
RevenueCat payment provider for molecule.dev.
Verifies a customer's in-app-purchase entitlements against RevenueCat's v1 REST API and parses RevenueCat's webhooks — one bond for App Store, Google Play, Amazon, Stripe, Paddle, Roku and RevenueCat Billing purchases, because RevenueCat has already normalized them.
REST over the runtime's global fetch — no vendor SDK, no HTTP dependency.
import { bond } from '@molecule/api-bond'
import { paymentProvider } from '@molecule/api-payments-revenuecat'
bond('payments', paymentProvider)
provider
npm install @molecule/api-payments-revenuecat @molecule/api-bond @molecule/api-config @molecule/api-payments @molecule/api-secrets
NormalizedPurchaseNormalized purchase information (for one-time purchases).
interface NormalizedPurchase {
/**
* The payment provider.
*/
provider: PaymentProviderName
/**
* The purchase/transaction ID.
*/
purchaseId: string
/**
* The product ID.
*/
productId: string
/**
* Whether the purchase is valid.
*/
isValid: boolean
/**
* When the purchase was made (Unix timestamp in ms).
*/
purchaseDate: number
/**
* Raw data from the provider.
*/
rawData: unknown
}
NormalizedSubscriptionNormalized subscription information.
Use this interface to abstract away provider-specific differences.
interface NormalizedSubscription {
/**
* The payment provider.
*/
provider: PaymentProviderName
/**
* The subscription ID from the provider.
*/
subscriptionId: string
/**
* The product/plan ID.
*/
productId: string
/**
* Current subscription status.
*/
status: SubscriptionStatus
/**
* Whether the subscription is currently active.
*/
isActive: boolean
/**
* When the current period started (Unix timestamp in ms).
*/
currentPeriodStart?: number
/**
* When the current period ends (Unix timestamp in ms).
*/
currentPeriodEnd?: number
/**
* Whether the subscription will auto-renew.
*/
willRenew?: boolean
/**
* When the subscription was canceled (if applicable).
*/
canceledAt?: number
/**
* Raw data from the provider.
*/
rawData: unknown
}
ParsedNotificationParsed server-to-server notification from a payment provider.
interface ParsedNotification {
transactionId?: string
productId?: string
type: string
expiresAt?: string
autoRenews?: boolean
}
ParsedSignatureHeaderThe parsed parts of an X-RevenueCat-Webhook-Signature header
(t=<unix_timestamp>,v1=<hmac_sha256_hex>).
interface ParsedSignatureHeader {
/** The Unix timestamp (seconds) RevenueCat signed the request at. */
timestamp: string
/** The hex-encoded HMAC-SHA256 digest. */
signature: string
}
RevenueCatEntitlementOne entry of a customer's subscriber.entitlements map, keyed by entitlement
identifier (e.g. pro_cat).
interface RevenueCatEntitlement {
/** ISO 8601 expiry of the entitlement, or `null` for a lifetime grant. */
expires_date: string | null
/** ISO 8601 expiry of the billing grace period, or `null`. */
grace_period_expires_date?: string | null
/** The product identifier that granted this entitlement. */
product_identifier: string
/** ISO 8601 purchase date of the granting transaction. */
purchase_date: string
}
RevenueCatNonSubscriptionOne entry of a customer's subscriber.non_subscriptions map (one-time
purchases), keyed by product identifier.
interface RevenueCatNonSubscription {
/** RevenueCat's identifier for the purchase. */
id: string
/** Whether the purchase came from the store's sandbox environment. */
is_sandbox?: boolean
/** ISO 8601 purchase date. */
purchase_date: string
/** The store the purchase belongs to. */
store?: string
}
RevenueCatSubscriberThe subscriber object of a RevenueCat customer-info response.
interface RevenueCatSubscriber {
/** Entitlements keyed by entitlement identifier. */
entitlements?: Record<string, RevenueCatEntitlement>
/** ISO 8601 time this customer was first seen. */
first_seen?: string
/** Store URL where the customer manages their subscription, or `null`. */
management_url?: string | null
/** One-time purchases keyed by product identifier. */
non_subscriptions?: Record<string, RevenueCatNonSubscription[]>
/** The App User ID this customer was first known by. */
original_app_user_id?: string
/** ISO 8601 date of the customer's first purchase. */
original_purchase_date?: string | null
/** Subscriptions keyed by product identifier. */
subscriptions?: Record<string, RevenueCatSubscription>
}
RevenueCatSubscriberResponseResponse body of GET /v1/subscribers/{app_user_id}.
interface RevenueCatSubscriberResponse {
/** ISO 8601 time RevenueCat served the request. */
request_date?: string
/** Milliseconds since the epoch at which RevenueCat served the request. */
request_date_ms?: number
/** The customer's info. */
subscriber: RevenueCatSubscriber
}
RevenueCatSubscriptionOne entry of a customer's subscriber.subscriptions map, keyed by product
identifier (e.g. annual).
interface RevenueCatSubscription {
/** When a paused Google Play subscription resumes, or `null`. */
auto_resume_date?: string | null
/** ISO 8601 time a billing problem was first detected, or `null`. */
billing_issues_detected_at?: string | null
/** ISO 8601 expiry of the current period, or `null` for a lifetime purchase. */
expires_date: string | null
/** ISO 8601 expiry of the billing grace period, or `null`. */
grace_period_expires_date?: string | null
/** Whether the purchase came from the store's sandbox environment. */
is_sandbox?: boolean
/** ISO 8601 date of the first purchase in this subscription. */
original_purchase_date?: string | null
/** `PURCHASED` or `FAMILY_SHARED`. */
ownership_type?: string
/** `normal`, `trial`, `intro`, `promotional`, or `prepaid`. */
period_type?: string
/** ISO 8601 date of the transaction that started the current period. */
purchase_date: string
/** ISO 8601 time the purchase was refunded, or `null`. */
refunded_at?: string | null
/** The store the purchase belongs to (e.g. `app_store`, `play_store`). */
store?: string
/**
* The store's identifier for the latest transaction.
*
* RevenueCat serializes this as a NUMBER for App Store purchases and a
* string elsewhere, so always coerce before comparing.
*/
store_transaction_id?: string | number | null
/** ISO 8601 time the customer turned auto-renew OFF, or `null`. */
unsubscribe_detected_at?: string | null
}
RevenueCatSubscriptionMatchA subscription resolved out of a customer's info, together with the identifiers it was matched by.
interface RevenueCatSubscriptionMatch {
/** The store product identifier that keys `subscriber.subscriptions`. */
productId: string
/** The entitlement identifier that granted it, when the match came through one. */
entitlementId?: string
/** The matched subscription. */
subscription: RevenueCatSubscription
}
RevenueCatWebhookBodyThe body RevenueCat POSTs to a webhook URL.
interface RevenueCatWebhookBody {
/** The webhook payload version (currently `'1.0'`). */
api_version?: string
/** The event itself. */
event?: RevenueCatWebhookEventPayload
}
RevenueCatWebhookEventPayloadThe event object of a RevenueCat webhook body (the fields this bond reads).
interface RevenueCatWebhookEventPayload {
/** Every App User ID this subscriber has ever used. */
aliases?: string[] | null
/** The subscriber's LAST SEEN App User ID, which may be an alias. */
app_user_id?: string
/**
* The subscriber's FIRST App User ID — stable across aliasing, and therefore
* the customer id this bond keys on. Omitted from
* `TEMPORARY_ENTITLEMENT_GRANT`, which carries only `app_user_id`.
*/
original_app_user_id?: string
/** Reason a `CANCELLATION` fired (e.g. `UNSUBSCRIBE`, `CUSTOMER_SUPPORT`). */
cancel_reason?: string
/** Entitlement identifiers the product maps to, or `null`. */
entitlement_ids?: string[] | null
/** `SANDBOX` or `PRODUCTION`. */
environment?: string
/** Milliseconds since the epoch at which the transaction expires, or `null`. */
expiration_at_ms?: number | null
/** Reason an `EXPIRATION` fired. */
expiration_reason?: string
/** RevenueCat's unique identifier for this event. */
id?: string
/** `TRIAL`, `INTRO`, `NORMAL`, `PROMOTIONAL`, or `PREPAID`. */
period_type?: string
/** The store product identifier. */
product_id?: string
/** Milliseconds since the epoch at which the transaction was purchased. */
purchased_at_ms?: number
/** The store the purchase belongs to (e.g. `APP_STORE`). */
store?: string
/** The event type (e.g. `INITIAL_PURCHASE`, `RENEWAL`, `CANCELLATION`). */
type?: string
}
VerifiedSubscriptionResult of verifying a subscription or receipt.
interface VerifiedSubscription {
productId: string
/**
* The provider's PRICE identifier for the purchased plan (e.g. a Stripe
* `price_…` id), when the provider distinguishes prices from products.
*
* Apps typically configure their plan catalogue with price ids (that is
* what checkout is started with and what env vars like
* `STRIPE_<APP>_PRO_MONTHLY` hold), while providers report the parent
* product id on subscriptions — so plan resolution should try BOTH
* `productId` and `priceId` against the registered plans.
*/
priceId?: string
transactionId?: string
expiresAt?: string
autoRenews?: boolean
data?: unknown
}
WebhookEventParsed webhook event from a payment provider.
interface WebhookEvent {
type: string
subscription?: {
customerId?: string
productId?: string
/**
* The provider's PRICE identifier for the subscribed plan (e.g. a Stripe
* `price_…` id). Apps register their plan catalogue with price ids (see
* {@link VerifiedSubscription.priceId}), so plan resolution should try
* BOTH `productId` and `priceId`.
*/
priceId?: string
expiresAt?: string
autoRenews?: boolean
/**
* Normalized subscription status at the time of the event.
*
* Surfaced so the notification handler can apply the SAME entitlement gate
* the verify path uses: only an active/trialing subscription confers the
* plan. A past_due/unpaid/incomplete subscription (e.g. a renewal-payment
* failure that still advances the period end) must NOT extend entitlement.
*/
status?: SubscriptionStatus
/**
* Whether the subscription is currently active (status active/trialing).
*
* Mirrors {@link NormalizedSubscription.isActive}. When `false`, the
* notification handler must not grant/extend the plan.
*/
isActive?: boolean
}
}
PaymentProviderPayment provider bond interface.
Each payment provider implements the methods relevant to its platform. All methods are optional since different platforms use different flows.
type PaymentProvider = PaymentProviderInterface
SubscriptionStatusSubscription status across providers.
type SubscriptionStatus =
'active' | 'canceled' | 'expired' | 'past_due' | 'trialing' | 'paused' | 'pending' | 'unknown'
RevenueCatApiErrorAn error returned by the RevenueCat REST API.
Deliberately NOT tagged with statusCode/errorKey — those tags make the
API middleware echo the status to the caller, and RevenueCat's status
(a 401 for a bad server key, a 429 for our own rate limit) is about OUR
configuration, not about the caller's request. An unconfigured key throws
the tagged {@link configNotConfiguredError} instead, before any request.
assertPurchaseEnvironmentAllowed(isSandbox)Rejects a store-sandbox purchase unless REVENUECAT_ALLOW_SANDBOX=true.
function assertPurchaseEnvironmentAllowed(isSandbox: boolean | undefined): void
isSandbox — Whether the purchase came from the store's sandbox environment.authenticateWebhook(rawBody, headers, now)Authenticates an incoming RevenueCat webhook.
FAILS CLOSED. RevenueCat offers two independent mechanisms — a shared
Authorization header value and an HMAC-SHA256 signature — and this bond
requires whichever ones are configured to ALL pass. If NEITHER
REVENUECAT_WEBHOOK_SIGNING_SECRET nor REVENUECAT_WEBHOOK_AUTHORIZATION is
set, the webhook endpoint is a URL anyone can POST a plan grant to, so this
throws a tagged config-not-configured error rather than accepting it.
function authenticateWebhook(
rawBody: string | Buffer<ArrayBufferLike>,
headers: Record<string, string | string[] | undefined> | undefined,
now?: number,
): boolean
rawBody — The raw request body EXACTLY as received (needed for the HMAC digest).headers — The request headers.now — Current time in milliseconds since the epoch; injectable for tests.Returns: true when every configured mechanism passed.
constantTimeEquals(a, b)Compares two strings in constant time.
timingSafeEqual throws on a length mismatch, which would itself leak the
expected length, so both sides are hashed to a fixed-width digest first.
function constantTimeEquals(a: string, b: string): boolean
a — The first string.b — The second string.Returns: true when the strings are byte-identical.
findSubscription(subscriber, identifier)Resolves an application-configured plan identifier to one of a customer's subscriptions.
Apps register their catalogue with EITHER a RevenueCat entitlement
identifier (pro) or a store product identifier (com.example.pro.monthly),
and both are legitimate, so both are tried: entitlements first (they are the
identifier RevenueCat's own docs steer developers toward), then the
subscriptions map keyed by store product id.
function findSubscription(
subscriber: RevenueCatSubscriber,
identifier: string,
): RevenueCatSubscriptionMatch | null
subscriber — The subscriber object from a customer-info response.identifier — The entitlement identifier or store product identifier to resolve.Returns: The matched subscription plus the identifiers it was matched by, or null when the customer has no such subscription.
getBaseUrl()Resolves the RevenueCat REST base URL, honouring the REVENUECAT_BASE_URL
override. Read on every call so a late-resolved config value is honoured.
function getBaseUrl(): string
Returns: The base URL with any trailing slash removed.
getEffectiveExpiry(subscription)The moment a subscription actually stops conferring access — its expiry, or the end of the billing grace period when RevenueCat has opened one (a customer inside a grace period has NOT lost access yet).
function getEffectiveExpiry(subscription: RevenueCatSubscription): number | undefined
subscription — The RevenueCat subscription.Returns: The effective expiry in milliseconds since the epoch, or undefined when the subscription carries no expiry at all.
getLatestSubscription(subscriber)Returns the customer's subscription with the latest effective expiry.
function getLatestSubscription(subscriber: RevenueCatSubscriber): RevenueCatSubscriptionMatch | null
subscriber — The subscriber object from a customer-info response.Returns: The longest-lived subscription plus its identifiers, or null when the customer has no subscription carrying an expiry.
getStoreTransactionId(subscription)The store's identifier for a subscription's latest transaction, coerced to a string.
RevenueCat serializes store_transaction_id as a NUMBER for App Store
purchases and a string elsewhere, so a bare === against a stored value
silently fails for iOS customers.
function getStoreTransactionId(subscription: RevenueCatSubscription): string | undefined
subscription — The RevenueCat subscription.Returns: The transaction id as a string, or undefined when absent.
getSubscriber(appUserId)Fetches a customer's info from RevenueCat.
GET /subscribers/{app_user_id} is a get-OR-CREATE endpoint: an App User ID
RevenueCat has never seen comes back 201 with an empty customer rather than
404, so "no such customer" and "customer with no purchases" are the same
response and both correctly yield no entitlement.
function getSubscriber(appUserId: string): Promise<RevenueCatSubscriberResponse>
appUserId — The RevenueCat App User ID to look up.Returns: The customer-info response body.
isSubscriptionActive(subscription)Whether a RevenueCat subscription currently confers access.
A refunded purchase is never active — RevenueCat keeps returning it with its
original future expires_date after a refund and only sets refunded_at, so
without this gate a refunded customer could re-verify the same App User ID to
re-grant the plan until the original period end.
function isSubscriptionActive(subscription: RevenueCatSubscription | null): boolean
subscription — The subscription to check, or null.Returns: true when the subscription is unrefunded and its effective expiry is in the future.
mapEventStatus(event, now)Derives the normalized subscription status a RevenueCat event leaves the customer in.
function mapEventStatus(event: RevenueCatWebhookEventPayload, now?: number): SubscriptionStatus
event — The RevenueCat webhook event payload.now — Current time in milliseconds since the epoch; injectable for tests.Returns: The normalized status.
mapEventType(event)Maps a RevenueCat event type to the simplified vocabulary.
CANCELLATION is the one type that cannot be mapped from the type alone.
RevenueCat fires it BOTH when a customer merely turns auto-renew off (they
keep access until expiration_at_ms) and when a purchase is refunded. Mapping
every CANCELLATION to canceled would revoke a plan the customer has
already paid for, weeks early — so only a refund (cancel_reason of
CUSTOMER_SUPPORT) maps to a revoking type; the rest map to unsubscribed,
which leaves the entitlement in place and simply reports autoRenews: false.
function mapEventType(event: RevenueCatWebhookEventPayload): string
event — The RevenueCat webhook event payload.Returns: The simplified event type; unrecognized types fall through to the raw type lower-cased.
normalizeSubscription(productId, subscription)Normalizes a RevenueCat subscription to the provider-agnostic
NormalizedSubscription interface.
isActive is derived from the normalized STATUS (active/trialing only), not
from the raw expiry, so a subscription inside an unresolved billing problem
or a pending pause does not read as entitling — the same rule the payments
core's isActiveStatus applies.
function normalizeSubscription(
productId: string,
subscription: RevenueCatSubscription,
): NormalizedSubscription
productId — The store product identifier keying this subscription.subscription — The RevenueCat subscription to normalize.Returns: A NormalizedSubscription with provider set to 'revenuecat' and dates converted to millisecond timestamps.
normalizeSubscriptionStatus(subscription)Maps a RevenueCat subscription onto the payments core's normalized status vocabulary.
function normalizeSubscriptionStatus(subscription: RevenueCatSubscription): SubscriptionStatus
subscription — The subscription to classify.Returns: The normalized status: canceled when refunded, expired past its effective expiry, paused while a Google Play pause is pending resume, past_due while a billing problem is unresolved, trialing during a free trial, otherwise active.
parseSignatureHeader(header)Parses an X-RevenueCat-Webhook-Signature header value.
function parseSignatureHeader(header: string): ParsedSignatureHeader | null
header — The raw header value, e.g. t=1700000000,v1=abc123….Returns: The parsed t and v1 parts, or null when either is missing or the header is malformed.
parseWebhookEvent(body, now)Parses an AUTHENTICATED RevenueCat webhook body into a normalized
WebhookEvent.
Call {@link authenticateWebhook} first — this function performs no authentication of its own, because the signature covers the raw bytes and this receives the parsed object.
function parseWebhookEvent(
body: RevenueCatWebhookBody | undefined,
now?: number,
): WebhookEvent | null
body — The parsed webhook body ({ api_version, event }).now — Current time in milliseconds since the epoch; injectable for tests.Returns: The normalized event, or null when the body carries no usable event or the purchase is a sandbox purchase and REVENUECAT_ALLOW_SANDBOX is not enabled.
readHeader(headers, name)Reads a header case-insensitively from a request's header map.
function readHeader(
headers: Record<string, string | string[] | undefined> | undefined,
name: string,
): string | undefined
headers — The request headers.name — The lower-case header name to read.Returns: The header value, or undefined when absent. Repeated headers yield the first value.
sandboxPurchasesAllowed()Whether sandbox purchases may be accepted as real entitlements.
function sandboxPurchasesAllowed(): boolean
Returns: true only when REVENUECAT_ALLOW_SANDBOX is explicitly 'true'.
verifyWebhookAuthorization(header, expected)Verifies the shared Authorization header RevenueCat sends when one is
configured on the webhook integration.
function verifyWebhookAuthorization(header: string | undefined, expected: string): boolean
header — The Authorization header value from the request, if any.expected — The value configured on the RevenueCat webhook integration.Returns: true only when the header is present and byte-identical to expected.
verifyWebhookSignature(rawBody, header, secret, toleranceSeconds, now)Verifies a RevenueCat webhook's HMAC-SHA256 signature.
FAILS CLOSED — returns false (never throws, never "assume valid") when:
t / v1 partsv1t is further from now than toleranceSeconds (replay window)function verifyWebhookSignature(
rawBody: string | Buffer<ArrayBufferLike>,
header: string,
secret: string,
toleranceSeconds?: number,
now?: number,
): boolean
rawBody — The raw request body EXACTLY as received. Re-serializing a parsed object (JSON.parse → JSON.stringify) changes the bytes and fails verification on valid requests — Express needs express.raw() (or a rawBody capture in its json verify hook).header — The X-RevenueCat-Webhook-Signature header value.secret — The webhook integration's HMAC signing secret.toleranceSeconds — Maximum accepted age of the signature, in seconds. Defaults to 300. RevenueCat re-signs every retry, so this only needs to cover clock skew and request latency — never the 5/10/20/40/80-minute retry delays.now — Current time in milliseconds since the epoch; injectable for tests.Returns: true only when the signature is authentic and inside the replay window.
willSubscriptionRenew(subscription)Whether a subscription will renew at the end of the current period.
Read from unsubscribe_detected_at — the ONLY field RevenueCat uses to
report that the customer turned auto-renew off, independent of whether they
are still paid-through. A customer who unsubscribes mid-period keeps access
until expiry, so inferring "will renew" from "is active" reports true right
up until the subscription silently lapses.
function willSubscriptionRenew(subscription: RevenueCatSubscription): boolean
subscription — The subscription to check.Returns: false when the customer unsubscribed or was refunded, true otherwise.
DEFAULT_SIGNATURE_TOLERANCE_SECONDSDefault maximum age, in seconds, of a signed webhook before it is treated as a replay.
const DEFAULT_SIGNATURE_TOLERANCE_SECONDS: 300
paymentProviderPaymentProvider-compatible adapter for RevenueCat.
Implements verifyReceipt (receipt-style verification against a customer's
RevenueCat entitlements) and handleWebhookEvent (authenticated webhook
parsing) from the PaymentProvider interface.
const paymentProvider: PaymentProviderInterface
paymentsRevenueCatSecretDefinitionsSecret definitions required by the RevenueCat payments bond.
The two webhook secrets are marked optional because RevenueCat offers TWO
independent webhook authentication mechanisms and only one is needed — but
the bond refuses to accept an unauthenticated webhook, so at least one MUST
be set for handleWebhookEvent to work at all.
const paymentsRevenueCatSecretDefinitions: SecretDefinition[]
REVENUECAT_API_BASE_URLRevenueCat's public REST base URL, including the API version segment.
Override it with REVENUECAT_BASE_URL (a broker, a compatible endpoint, or
a test double); the override must also include the version segment.
const REVENUECAT_API_BASE_URL: 'https://api.revenuecat.com/v1'
REVENUECAT_SIGNATURE_HEADERThe header RevenueCat puts its HMAC signature in when HMAC signing is enabled.
const REVENUECAT_SIGNATURE_HEADER: 'x-revenuecat-webhook-signature'
Implements @molecule/api-payments interface.
Setup function to register this provider with the bond system:
import { bond } from '@molecule/api-bond'
import { paymentProvider } from '@molecule/api-payments-revenuecat'
export function setupPaymentsRevenuecat(): void {
bond('payments', 'revenuecat', paymentProvider)
}
Peer dependencies:
@molecule/api-bond ^1.0.1@molecule/api-config ^1.0.1@molecule/api-payments ^1.2.0@molecule/api-secrets ^1.0.1REVENUECAT_SECRET_API_KEY (required) — RevenueCat secret API key
sk_...REVENUECAT_WEBHOOK_AUTHORIZATION (optional) — RevenueCat webhook Authorization header value
Bearer ...REVENUECAT_WEBHOOK_SIGNING_SECRET (optional) — RevenueCat webhook HMAC signing secret
...@molecule/api-bond@molecule/api-config@molecule/api-payments@molecule/api-secretsFacts to know BEFORE wiring this bond:
verifyReceipt(appUserId, productId) just asks
"what does this customer own?". So anyone who learns another customer's App
User ID could claim their plan. Set the RevenueCat App User ID to your
OWN authenticated user id (Purchases.logIn(user.id) in the client SDK)
and have the verify route submit the id of the user it already
authenticated — never an arbitrary value from the request body. Anonymous
RevenueCat ids ($RCAnonymousID:…) must not be accepted.original_app_user_id, not by the id you sent.
RevenueCat aliases App User IDs (anonymous → logged-in, merges), and a
webhook's app_user_id is only the LAST SEEN one. Both verifyReceipt and
handleWebhookEvent therefore report the customer's FIRST id as
customerId, so the payment record written at verify time is the one the
webhook finds later. If you look customers up yourself, search the
aliases array too.REVENUECAT_SECRET_API_KEY is the SECRET (v1) key, not the public SDK
key. The public key ships inside your app and can only read; the secret
key is server-only. Both start with different prefixes in the dashboard —
Project settings → API keys → "Secret API keys".REVENUECAT_ALLOW_SANDBOX=true for local/CI testing only. This is NOT
gated on NODE_ENV, which fails open whenever a deploy forgets to set it.Authorization header value and/or
an HMAC-SHA256 X-RevenueCat-Webhook-Signature, both opt-in in the
dashboard. With NEITHER REVENUECAT_WEBHOOK_AUTHORIZATION nor
REVENUECAT_WEBHOOK_SIGNING_SECRET set, handleWebhookEvent throws a
tagged config-not-configured error rather than accepting a plan grant from
an unauthenticated POST. Whichever ones ARE set must all pass.JSON.parse → JSON.stringify
changes them and fails verification on valid requests, so the route must
expose req.rawBody (express.raw(), or the verify hook of
express.json()). handleWebhookEvent prefers rawBody and falls back to
re-serializing body, which only works when no signing secret is set.CANCELLATION is usually NOT a revocation. It fires when a
customer turns auto-renew off — they keep access until expiration_at_ms —
and also when a purchase is refunded. Only a refund (cancel_reason of
CUSTOMER_SUPPORT) maps to a revoking event type here; the rest map to
unsubscribed, which leaves the plan in place and reports
autoRenews: false. Revoke on EXPIRATION, never on every CANCELLATION.
Likewise SUBSCRIPTION_PAUSED and BILLING_ISSUE do not end access.verifyReceipt's productId
accepts a RevenueCat entitlement identifier (pro) or a store product
identifier (com.example.pro.monthly), and results carry the store product
id as productId and the entitlement id as priceId so the payments
core's "try productId, then priceId" plan resolution finds either.store_transaction_id is a NUMBER for App Store purchases and a string
everywhere else. Use getStoreTransactionId() rather than reading the raw
field, or an iOS customer's id silently fails a === against a stored one.updateSubscription/cancelSubscription — send the
customer to subscriber.management_url from their customer info to manage
or cancel. One-time (non-subscription) purchases are likewise out of scope:
an entitlement backed by a non_subscriptions purchase verifies as no
subscription.REVENUECAT_SECRET_API_KEY throws a tagged config error BEFORE
any network call (isConfigNotConfiguredError from
@molecule/api-payments), and verifyReceipt rethrows it rather than
swallowing it into the same null an unentitled customer returns. Every
other RevenueCat failure surfaces as {@link RevenueCatApiError}, which
carries the vendor status and code but is deliberately NOT tagged, so
the API answers its own generic error instead of echoing RevenueCat's.REVENUECAT_BASE_URL (a broker,
a compatible endpoint, a test double). It must include the version segment;
the default is https://api.revenuecat.com/v1.Integration checklist — drive the real UI (live preview, no mocks; use the provider's TEST mode — test cards/sandbox accounts, never a live charge), adapt each item to this app's actual screens/flows, and check every box off one by one. A box you can't check is an integration bug to fix — not a skip:
read_activity tool (filter type 'webhook'); never
mock the event or modify production code to fake an entitlement.