← All @molecule/* packages · App templates

@molecule/api-payments-revenuecat

Provider bond · payments · API (Node) · v1.0.1 · Apache-2.0

RevenueCat payment provider for molecule.dev.

npm install @molecule/api-payments-revenuecat

npm · Source on GitHub · Implements @molecule/api-payments

How it works

@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)

Reference

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.ts JSDoc, 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.

Quick Start

import { bond } from '@molecule/api-bond'
import { paymentProvider } from '@molecule/api-payments-revenuecat'

bond('payments', paymentProvider)

Type

provider

Installation

npm install @molecule/api-payments-revenuecat @molecule/api-bond @molecule/api-config @molecule/api-payments @molecule/api-secrets

API

Interfaces

NormalizedPurchase

Normalized 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
}

NormalizedSubscription

Normalized 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
}

ParsedNotification

Parsed server-to-server notification from a payment provider.

interface ParsedNotification {
  transactionId?: string
  productId?: string
  type: string
  expiresAt?: string
  autoRenews?: boolean
}

ParsedSignatureHeader

The 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
}

RevenueCatEntitlement

One 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
}

RevenueCatNonSubscription

One 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
}

RevenueCatSubscriber

The 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>
}

RevenueCatSubscriberResponse

Response 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
}

RevenueCatSubscription

One 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
}

RevenueCatSubscriptionMatch

A 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
}

RevenueCatWebhookBody

The body RevenueCat POSTs to a webhook URL.

interface RevenueCatWebhookBody {
  /** The webhook payload version (currently `'1.0'`). */
  api_version?: string
  /** The event itself. */
  event?: RevenueCatWebhookEventPayload
}

RevenueCatWebhookEventPayload

The 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
}

VerifiedSubscription

Result 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
}

WebhookEvent

Parsed 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
  }
}

Types

PaymentProvider

Payment 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

SubscriptionStatus

Subscription status across providers.

type SubscriptionStatus =
  'active' | 'canceled' | 'expired' | 'past_due' | 'trialing' | 'paused' | 'pending' | 'unknown'

Classes

RevenueCatApiError

An 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.

Functions

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:

  • the header is malformed or missing its t / v1 parts
  • the recomputed digest does not equal v1
  • t 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.parseJSON.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.

Constants

DEFAULT_SIGNATURE_TOLERANCE_SECONDS

Default maximum age, in seconds, of a signed webhook before it is treated as a replay.

const DEFAULT_SIGNATURE_TOLERANCE_SECONDS: 300

paymentProvider

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

paymentsRevenueCatSecretDefinitions

Secret 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_URL

RevenueCat'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_HEADER

The header RevenueCat puts its HMAC signature in when HMAC signing is enabled.

const REVENUECAT_SIGNATURE_HEADER: 'x-revenuecat-webhook-signature'

Core Interface

Implements @molecule/api-payments interface.

Bond Wiring

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)
}

Injection Notes

Requirements

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.1

Environment Variables

  • REVENUECAT_SECRET_API_KEY (required) — RevenueCat secret API key
    • Setup: RevenueCat dashboard → Project settings → API keys → "Secret API keys" → New; this is the server-only key, NOT the public SDK key that ships inside your app.
    • Get it here: https://app.revenuecat.com/
    • Example: sk_...
  • REVENUECAT_WEBHOOK_AUTHORIZATION (optional) — RevenueCat webhook Authorization header value
    • Setup: RevenueCat dashboard → Integrations → Webhooks → point the webhook at {apiUrl}/api/users/payment-notification/revenuecat, set "Authorization header value" to a long random string, and paste the same string here.
    • Get it here: https://app.revenuecat.com/
    • Example: Bearer ...
  • REVENUECAT_WEBHOOK_SIGNING_SECRET (optional) — RevenueCat webhook HMAC signing secret
    • Setup: RevenueCat dashboard → Integrations → Webhooks → enable "HMAC webhook signing" and copy the secret; it is shown only at creation or rotation and cannot be retrieved later.
    • Get it here: https://app.revenuecat.com/
    • Example: ...

Runtime Dependencies

  • @molecule/api-bond
  • @molecule/api-config
  • @molecule/api-payments
  • @molecule/api-secrets

Facts to know BEFORE wiring this bond:

  • The App User ID is an IDENTIFIER, not a proof of purchase. Apple and Google receipt bonds verify a cryptographic artifact only the buyer's device holds; RevenueCat's 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.
  • Customers are keyed by 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".
  • Sandbox purchases are rejected by default (fail-closed). A store-sandbox purchase costs nothing, so accepting one grants a real plan for free. Set 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.
  • Webhook authentication is fail-closed and there is no default. RevenueCat sends webhooks with a shared 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.
  • The HMAC covers the RAW request bytes. JSON.parseJSON.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.
  • A RevenueCat 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.
  • Register plans under either identifier. 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.
  • This bond verifies; it does not sell. RevenueCat purchases happen in the store or the client SDK, so there is no server-created checkout session, no billing portal, and no 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.
  • A missing 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.
  • Point the base URL somewhere else with 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.

E2E Tests

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:

  • Starting an upgrade/subscribe from the pricing or billing surface creates a checkout session and hands off to the provider flow (redirect or embedded element) — the button does something real, not a dead click.
  • Returning from a canceled/abandoned checkout leaves the user on their original plan with a sane UI (no phantom entitlement, no error page).
  • Entitlement flips ONLY after server-side verification (webhook or verify call) — reloading after a client-side-only "success" must NOT show a paid plan unless the server verified it. The sandbox CAPTURES webhook deliveries — read them with the read_activity tool (filter type 'webhook'); never mock the event or modify production code to fake an entitlement.
  • The current subscription status (plan name, renewal/expiry) renders on the account/billing screen, and canceling updates that status visibly.
  • With payment secrets unconfigured, the flow surfaces an actionable "credentials not configured" message — not a silent no-op or generic 500.
  • The provider SECRET key never reaches the browser (page + network traffic contain only the publishable key).