revenuecat-server 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 despia-native
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,213 @@
1
+ # revenuecat-server
2
+
3
+ A lightweight server-side SDK for the [RevenueCat v2 REST API](https://www.revenuecat.com/docs/api-v2). Makes entitlement checks and offering/package management simple — so you can follow security best practices without wrestling with webhooks or complex API calls.
4
+
5
+ **Works with:** Node.js 18+, Deno, Bun — and any backend (Express, Fastify, Hono, Next.js API routes, Lambda, etc.). Zero dependencies.
6
+
7
+ > **Server-side only.** API keys must **never** be on the frontend. Use this SDK in your backend only. Build secure API proxies: your frontend calls *your* backend; your backend uses this SDK to fetch offerings, check entitlements, etc. Never expose RevenueCat API keys to the client.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ npm install revenuecat-server
13
+ ```
14
+
15
+ ```bash
16
+ pnpm add revenuecat-server
17
+ ```
18
+
19
+ ```bash
20
+ yarn add revenuecat-server
21
+ ```
22
+
23
+ ## Requirements
24
+
25
+ - Node.js 18+ (uses native `fetch`), or Deno/Bun
26
+
27
+ ## Setup
28
+
29
+ ```js
30
+ const { RevenueCatClient } = require("revenuecat-server");
31
+
32
+ const rc = new RevenueCatClient({
33
+ apiKey: process.env.REVENUECAT_API_KEY, // v2 secret key
34
+ projectId: process.env.REVENUECAT_PROJECT_ID,
35
+ });
36
+ ```
37
+
38
+ ---
39
+
40
+ ## API
41
+
42
+ ### Customers
43
+
44
+ #### `getCustomer(customerId, options?)`
45
+
46
+ Fetch a single customer. `active_entitlements` is always included.
47
+
48
+ ```js
49
+ const customer = await rc.getCustomer("user-uuid");
50
+
51
+ // With attributes expanded
52
+ const customer = await rc.getCustomer("user-uuid", {
53
+ expand: ["attributes"],
54
+ });
55
+ ```
56
+
57
+ ---
58
+
59
+ #### `getActiveEntitlements(customerId)`
60
+
61
+ Returns all active entitlements as a flat array (auto-paginates).
62
+
63
+ ```js
64
+ const entitlements = await rc.getActiveEntitlements("user-uuid");
65
+ // [{ entitlement_id: "premium", expires_at: 1234567890000 }, ...]
66
+ ```
67
+
68
+ ---
69
+
70
+ #### `hasEntitlement(customerId, entitlementId)`
71
+
72
+ Quick boolean check for a specific entitlement.
73
+
74
+ ```js
75
+ const isPremium = await rc.hasEntitlement("user-uuid", "premium");
76
+ // true | false
77
+ ```
78
+
79
+ ---
80
+
81
+ #### `requireEntitlement(customerId, entitlementId)`
82
+
83
+ Throws `AccessDeniedError` if the customer lacks the entitlement. Ideal for API route guards.
84
+
85
+ ```js
86
+ try {
87
+ await rc.requireEntitlement("user-uuid", "premium");
88
+ // User has access — serve premium content or proceed with operation
89
+ } catch (err) {
90
+ if (err instanceof AccessDeniedError) {
91
+ return res.status(403).json({ error: "Premium required" });
92
+ }
93
+ throw err;
94
+ }
95
+ ```
96
+
97
+ ---
98
+
99
+ #### `requireAnyEntitlement(customerId, entitlementIds)`
100
+
101
+ Throws `AccessDeniedError` if the customer has **none** of the given entitlements. Use for "any premium tier" checks.
102
+
103
+ ```js
104
+ await rc.requireAnyEntitlement("user-uuid", ["premium", "pro", "enterprise"]);
105
+ ```
106
+
107
+ ---
108
+
109
+ ### Server-side access control
110
+
111
+ | Need | Use |
112
+ |------|-----|
113
+ | Gate an API route or operation | `requireEntitlement(customerId, "premium")` |
114
+ | Conditional logic / custom UI | `hasEntitlement(customerId, "premium")` |
115
+ | "Any of these tiers" check | `requireAnyEntitlement(customerId, ["premium", "pro"])` |
116
+ | Sync entitlements to your DB (cron) | `getActiveEntitlements(customerId)` |
117
+ | Handle access denied | `catch (err) { if (err instanceof AccessDeniedError) { ... } }` |
118
+
119
+ You can rely on live checks instead of webhooks if that fits your stack better — this SDK keeps the flow simple.
120
+
121
+ ---
122
+
123
+ ### Offerings
124
+
125
+ #### `getOffering(offeringId, options?)`
126
+
127
+ Fetch a raw offering. Defaults to `expand=["package.product"]`.
128
+
129
+ ```js
130
+ const offering = await rc.getOffering("ofrnge1a2b3c4d5");
131
+ ```
132
+
133
+ ---
134
+
135
+ #### `getProductsByPlatform(offeringId, packageId)`
136
+
137
+ Get products for a specific package, grouped by platform.
138
+
139
+ ```js
140
+ const products = await rc.getProductsByPlatform(
141
+ "ofrnge1a2b3c4d5",
142
+ "pkge1a2b3c4d5"
143
+ );
144
+ // {
145
+ // ios: [{ store_identifier: "com.app.monthly", display_name: "...", ... }],
146
+ // android: [{ store_identifier: "com.app.monthly.android", ... }],
147
+ // other: []
148
+ // }
149
+ ```
150
+
151
+ ---
152
+
153
+ #### `getOfferingPackages(offeringId)`
154
+
155
+ All packages in an offering, each with products grouped by platform. Auto-paginates both packages and products.
156
+
157
+ ```js
158
+ const packages = await rc.getOfferingPackages("ofrnge1a2b3c4d5");
159
+ // [
160
+ // {
161
+ // id: "pkge1a2b3c4d5",
162
+ // lookup_key: "monthly",
163
+ // display_name: "Monthly",
164
+ // position: 1,
165
+ // products: { ios: [...], android: [...], other: [...] }
166
+ // }
167
+ // ]
168
+ ```
169
+
170
+ ---
171
+
172
+ ## Error Handling
173
+
174
+ - **RevenueCatError** — Thrown on API errors (4xx, 5xx). Has `status` and `body`.
175
+ - **AccessDeniedError** — Thrown by `requireEntitlement` and `requireAnyEntitlement` when access is denied. Has `customerId` and `entitlementIds`.
176
+
177
+ ```js
178
+ const { RevenueCatError, AccessDeniedError } = require("revenuecat-server");
179
+
180
+ try {
181
+ const customer = await rc.getCustomer("bad-id");
182
+ } catch (err) {
183
+ if (err instanceof RevenueCatError) {
184
+ console.error(err.status); // 404
185
+ console.error(err.body); // { message: "Not found" }
186
+ }
187
+ if (err instanceof AccessDeniedError) {
188
+ console.error(err.customerId, err.entitlementIds);
189
+ }
190
+ }
191
+ ```
192
+
193
+ ---
194
+
195
+ ## Notes
196
+
197
+ - **Server-side only** — Run this SDK in your backend. Expose data to your frontend via secure proxy endpoints (e.g. `GET /api/offerings`, `GET /api/me/entitlements`). Your frontend never touches RevenueCat directly.
198
+ - **Prices are not available** via RevenueCat — fetch those from Apple StoreKit / Google Play Billing directly using `store_identifier`.
199
+ - Your API key must have the correct permissions:
200
+ - `customer_information:customers:read` — for customer/entitlement endpoints
201
+ - `project_configuration:packages:read` — for packages
202
+ - `project_configuration:products:read` — for products
203
+ - Rate limits: **480 req/min** for customer endpoints, **60 req/min** for offering/project config endpoints.
204
+
205
+ ## Run Tests
206
+
207
+ ```bash
208
+ npm test
209
+ # or
210
+ pnpm test
211
+ # or
212
+ yarn test
213
+ ```
package/index.d.ts ADDED
@@ -0,0 +1,74 @@
1
+ /**
2
+ * revenuecat-server — Server-side SDK for RevenueCat v2 API
3
+ * @see https://www.revenuecat.com/docs/api-v2
4
+ */
5
+
6
+ export interface RevenueCatClientConfig {
7
+ apiKey: string;
8
+ projectId: string;
9
+ }
10
+
11
+ export interface GetCustomerOptions {
12
+ expand?: string[];
13
+ }
14
+
15
+ export interface GetOfferingOptions {
16
+ expand?: string[];
17
+ }
18
+
19
+ export interface ProductsByPlatform {
20
+ ios: Product[];
21
+ android: Product[];
22
+ other: Product[];
23
+ }
24
+
25
+ export interface PackageWithProducts {
26
+ id: string;
27
+ lookup_key: string;
28
+ display_name: string;
29
+ position: number;
30
+ products: ProductsByPlatform;
31
+ }
32
+
33
+ export interface ActiveEntitlement {
34
+ entitlement_id: string;
35
+ expires_at?: number;
36
+ [key: string]: unknown;
37
+ }
38
+
39
+ export interface Product {
40
+ id: string;
41
+ store_identifier: string;
42
+ display_name?: string;
43
+ type?: string;
44
+ app?: { type?: string };
45
+ subscription?: { duration?: string; trial_duration?: string | null };
46
+ eligibility_criteria?: string;
47
+ [key: string]: unknown;
48
+ }
49
+
50
+ export class RevenueCatClient {
51
+ constructor(config: RevenueCatClientConfig);
52
+
53
+ getCustomer(customerId: string, options?: GetCustomerOptions): Promise<Record<string, unknown>>;
54
+ getActiveEntitlements(customerId: string): Promise<ActiveEntitlement[]>;
55
+ hasEntitlement(customerId: string, entitlementId: string): Promise<boolean>;
56
+ requireEntitlement(customerId: string, entitlementId: string): Promise<void>;
57
+ requireAnyEntitlement(customerId: string, entitlementIds: string[]): Promise<void>;
58
+
59
+ getOffering(offeringId: string, options?: GetOfferingOptions): Promise<Record<string, unknown>>;
60
+ getProductsByPlatform(offeringId: string, packageId: string): Promise<ProductsByPlatform>;
61
+ getOfferingPackages(offeringId: string): Promise<PackageWithProducts[]>;
62
+ }
63
+
64
+ export class RevenueCatError extends Error {
65
+ status: number;
66
+ body: Record<string, unknown>;
67
+ constructor(status: number, body: Record<string, unknown>);
68
+ }
69
+
70
+ export class AccessDeniedError extends Error {
71
+ customerId: string;
72
+ entitlementIds: string[];
73
+ constructor(customerId: string, entitlementIdOrIds: string | string[]);
74
+ }
package/index.js ADDED
@@ -0,0 +1,7 @@
1
+ const {
2
+ RevenueCatClient,
3
+ RevenueCatError,
4
+ AccessDeniedError,
5
+ } = require("./src/RevenueCatClient");
6
+
7
+ module.exports = { RevenueCatClient, RevenueCatError, AccessDeniedError };
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "revenuecat-server",
3
+ "version": "1.0.0",
4
+ "description": "Lightweight server-side SDK for the RevenueCat v2 API. Entitlement checks, offerings, packages — works with any backend (Node, Deno, Bun).",
5
+ "main": "index.js",
6
+ "types": "index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./index.d.ts",
10
+ "require": "./index.js",
11
+ "import": "./index.js",
12
+ "default": "./index.js"
13
+ }
14
+ },
15
+ "files": [
16
+ "index.js",
17
+ "index.d.ts",
18
+ "src"
19
+ ],
20
+ "scripts": {
21
+ "test": "node tests/client.test.js"
22
+ },
23
+ "keywords": [
24
+ "revenuecat",
25
+ "subscriptions",
26
+ "entitlements",
27
+ "iap",
28
+ "in-app-purchase",
29
+ "server",
30
+ "backend",
31
+ "paywall"
32
+ ],
33
+ "license": "MIT",
34
+ "engines": {
35
+ "node": ">=18.0.0"
36
+ },
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "https://github.com/yourusername/revenuecat-server"
40
+ },
41
+ "bugs": {
42
+ "url": "https://github.com/yourusername/revenuecat-server/issues"
43
+ },
44
+ "homepage": "https://github.com/yourusername/revenuecat-server#readme",
45
+ "sideEffects": false
46
+ }
@@ -0,0 +1,242 @@
1
+ const BASE_URL = "https://api.revenuecat.com/v2";
2
+
3
+ class RevenueCatClient {
4
+ constructor({ apiKey, projectId }) {
5
+ if (!apiKey) throw new Error("RevenueCatClient: apiKey is required");
6
+ if (!projectId) throw new Error("RevenueCatClient: projectId is required");
7
+
8
+ this.apiKey = apiKey;
9
+ this.projectId = projectId;
10
+ }
11
+
12
+ // ─── Internal ────────────────────────────────────────────────────────────────
13
+
14
+ get #headers() {
15
+ return {
16
+ Authorization: `Bearer ${this.apiKey}`,
17
+ "Content-Type": "application/json",
18
+ };
19
+ }
20
+
21
+ async #get(path, params = {}) {
22
+ const url = new URL(`${BASE_URL}${path}`);
23
+ for (const [key, value] of Object.entries(params)) {
24
+ if (Array.isArray(value)) {
25
+ value.forEach((v) => url.searchParams.append(key, v));
26
+ } else {
27
+ url.searchParams.set(key, value);
28
+ }
29
+ }
30
+
31
+ const response = await fetch(url.toString(), { headers: this.#headers });
32
+
33
+ if (!response.ok) {
34
+ const error = await response.json().catch(() => ({}));
35
+ throw new RevenueCatError(response.status, error);
36
+ }
37
+
38
+ return response.json();
39
+ }
40
+
41
+ async #paginate(firstPage, getItems, getNextPage) {
42
+ const items = [...getItems(firstPage)];
43
+ let nextPage = getNextPage(firstPage);
44
+
45
+ while (nextPage) {
46
+ const page = await this.#get(nextPage);
47
+ items.push(...getItems(page));
48
+ nextPage = getNextPage(page);
49
+ }
50
+
51
+ return items;
52
+ }
53
+
54
+ // ─── Customers ───────────────────────────────────────────────────────────────
55
+
56
+ /**
57
+ * Get a customer by ID.
58
+ * @param {string} customerId
59
+ * @param {{ expand?: string[] }} options
60
+ */
61
+ async getCustomer(customerId, { expand = [] } = {}) {
62
+ const params = expand.length ? { expand } : {};
63
+ return this.#get(
64
+ `/projects/${this.projectId}/customers/${customerId}`,
65
+ params
66
+ );
67
+ }
68
+
69
+ /**
70
+ * Get all active entitlements for a customer (auto-paginates).
71
+ * @param {string} customerId
72
+ * @returns {Promise<ActiveEntitlement[]>}
73
+ */
74
+ async getActiveEntitlements(customerId) {
75
+ const customer = await this.getCustomer(customerId);
76
+ return this.#paginate(
77
+ customer.active_entitlements,
78
+ (page) => page.items,
79
+ (page) => page.next_page
80
+ );
81
+ }
82
+
83
+ /**
84
+ * Check if a customer has a specific entitlement active.
85
+ * @param {string} customerId
86
+ * @param {string} entitlementId
87
+ * @returns {Promise<boolean>}
88
+ */
89
+ async hasEntitlement(customerId, entitlementId) {
90
+ const entitlements = await this.getActiveEntitlements(customerId);
91
+ return entitlements.some((e) => e.entitlement_id === entitlementId);
92
+ }
93
+
94
+ /**
95
+ * Require a specific entitlement — throws AccessDeniedError if the customer
96
+ * does not have it. Use in API route guards or before protected operations.
97
+ * @param {string} customerId
98
+ * @param {string} entitlementId
99
+ * @throws {AccessDeniedError} when customer lacks the entitlement
100
+ */
101
+ async requireEntitlement(customerId, entitlementId) {
102
+ const has = await this.hasEntitlement(customerId, entitlementId);
103
+ if (!has) {
104
+ throw new AccessDeniedError(customerId, entitlementId);
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Require at least one of the given entitlements — throws AccessDeniedError
110
+ * if the customer has none. Useful for "any premium tier" checks.
111
+ * @param {string} customerId
112
+ * @param {string[]} entitlementIds
113
+ * @throws {AccessDeniedError} when customer lacks all entitlements
114
+ */
115
+ async requireAnyEntitlement(customerId, entitlementIds) {
116
+ const entitlements = await this.getActiveEntitlements(customerId);
117
+ const ids = new Set(entitlements.map((e) => e.entitlement_id));
118
+ const hasAny = entitlementIds.some((id) => ids.has(id));
119
+ if (!hasAny) {
120
+ throw new AccessDeniedError(customerId, entitlementIds);
121
+ }
122
+ }
123
+
124
+ // ─── Offerings ───────────────────────────────────────────────────────────────
125
+
126
+ /**
127
+ * Get a full offering with packages and products.
128
+ * @param {string} offeringId
129
+ * @param {{ expand?: string[] }} options
130
+ */
131
+ async getOffering(offeringId, { expand = ["package.product"] } = {}) {
132
+ return this.#get(
133
+ `/projects/${this.projectId}/offerings/${offeringId}`,
134
+ { expand }
135
+ );
136
+ }
137
+
138
+ /**
139
+ * Get products for a specific package, grouped by platform.
140
+ * @param {string} offeringId
141
+ * @param {string} packageId
142
+ * @returns {Promise<{ ios: Product[], android: Product[], other: Product[] }>}
143
+ */
144
+ async getProductsByPlatform(offeringId, packageId) {
145
+ const offering = await this.getOffering(offeringId);
146
+
147
+ const pkg = offering.packages.items.find((p) => p.id === packageId);
148
+ if (!pkg) {
149
+ throw new Error(
150
+ `Package "${packageId}" not found in offering "${offeringId}"`
151
+ );
152
+ }
153
+
154
+ // Paginate products if needed
155
+ const products = await this.#paginate(
156
+ pkg.products,
157
+ (page) => page.items,
158
+ (page) => page.next_page
159
+ );
160
+
161
+ return groupProductsByPlatform(products);
162
+ }
163
+
164
+ /**
165
+ * Get all packages in an offering, each with products grouped by platform.
166
+ * @param {string} offeringId
167
+ * @returns {Promise<PackageWithProducts[]>}
168
+ */
169
+ async getOfferingPackages(offeringId) {
170
+ const offering = await this.getOffering(offeringId);
171
+
172
+ // Paginate packages if needed
173
+ const packages = await this.#paginate(
174
+ offering.packages,
175
+ (page) => page.items,
176
+ (page) => page.next_page
177
+ );
178
+
179
+ return Promise.all(
180
+ packages.map(async (pkg) => {
181
+ const products = await this.#paginate(
182
+ pkg.products,
183
+ (page) => page.items,
184
+ (page) => page.next_page
185
+ );
186
+ return {
187
+ id: pkg.id,
188
+ lookup_key: pkg.lookup_key,
189
+ display_name: pkg.display_name,
190
+ position: pkg.position,
191
+ products: groupProductsByPlatform(products),
192
+ };
193
+ })
194
+ );
195
+ }
196
+ }
197
+
198
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
199
+
200
+ function groupProductsByPlatform(productItems) {
201
+ const result = { ios: [], android: [], other: [] };
202
+
203
+ for (const { product, eligibility_criteria } of productItems) {
204
+ const appType = product.app?.type;
205
+ const entry = { ...product, eligibility_criteria };
206
+
207
+ if (appType === "app_store" || appType === "mac_app_store") {
208
+ result.ios.push(entry);
209
+ } else if (appType === "play_store") {
210
+ result.android.push(entry);
211
+ } else {
212
+ result.other.push(entry);
213
+ }
214
+ }
215
+
216
+ return result;
217
+ }
218
+
219
+ class RevenueCatError extends Error {
220
+ constructor(status, body) {
221
+ super(`RevenueCat API error ${status}: ${JSON.stringify(body)}`);
222
+ this.status = status;
223
+ this.body = body;
224
+ }
225
+ }
226
+
227
+ class AccessDeniedError extends Error {
228
+ constructor(customerId, entitlementIdOrIds) {
229
+ const ids =
230
+ typeof entitlementIdOrIds === "string"
231
+ ? entitlementIdOrIds
232
+ : entitlementIdOrIds.join(", ");
233
+ super(`Access denied: customer ${customerId} lacks entitlement(s) [${ids}]`);
234
+ this.customerId = customerId;
235
+ this.entitlementIds =
236
+ typeof entitlementIdOrIds === "string"
237
+ ? [entitlementIdOrIds]
238
+ : entitlementIdOrIds;
239
+ }
240
+ }
241
+
242
+ module.exports = { RevenueCatClient, RevenueCatError, AccessDeniedError };