# RevenueDot documentation (full text) > RevenueDot is an open-source (AGPL-3.0), self-hostable backend for in-app purchases and subscriptions that works with the RevenueCat SDK. > It is a free alternative to RevenueCat: an app points the RevenueCat SDK's proxy URL at a RevenueDot server and keeps its purchase code, offerings and customers. > It verifies App Store and Google Play purchases on the server, keeps each customer's entitlements current from store notifications, and sends webhooks in RevenueCat's payload format. > RevenueDot runs as one Docker image plus Postgres, or hosted as RevenueDot Cloud (dashboard https://app.revenuedot.app, API https://api.revenuedot.app, free plan). The SDK forks are MIT and keep RevenueCat's class and method names. RevenueDot is not affiliated with RevenueCat, Inc. Generated from https://github.com/revenuedot/docs by scripts/gen-llms.mjs. Index: https://revenuedot.app/llms.txt === Getting started === # What is RevenueDot? Source: https://revenuedot.app/docs/getting-started.md Description: RevenueDot is an open-source, self-hostable backend for in-app purchases and subscriptions that works with the RevenueCat SDK. Point the SDK's proxy URL at it and keep your app code. RevenueDot is an open-source (AGPL-3.0) server for in-app purchases and subscriptions that works with the RevenueCat SDK. You run it yourself with Docker and Postgres, or use **RevenueDot Cloud**: sign up at [app.revenuedot.app](https://app.revenuedot.app) and use `https://api.revenuedot.app`. An app that already uses the RevenueCat SDK points the SDK at RevenueDot with one setting, the **proxy URL**, and keeps its purchase code. ```swift // Point the SDK at your RevenueDot server; nothing else in the app changes. Purchases.proxyURL = URL(string: "https://revenuedot.example.com")! Purchases.configure(with: Configuration.Builder(withAPIKey: "appl_...").with(entitlementVerificationMode: .disabled).build()) ``` > Every page states what exists today and what is planned. Current limits are listed in [Known issues](https://revenuedot.app/docs/help/known-issues.md). ## What RevenueDot does - **Checks purchases with the stores.** App Store purchases are verified against Apple's signed transactions and the App Store Server API. Google Play purchases are verified with the Google Play Developer API. Nothing is trusted from the device alone. - **Keeps each customer's access up to date.** It turns store events (renewals, cancellations, billing problems, refunds, pauses) into **entitlements** that your app checks, such as `pro`. - **Serves your paywall's contents.** **Offerings** and **packages** tell the app which products to show. - **Tells your backend what happened.** Webhooks use RevenueCat's payload shape and carry an HMAC signature. - **Gives you APIs and a dashboard.** RevenueCat-compatible REST APIs (v1 and v2) and a web dashboard for the catalog, customers, apps, API keys and webhooks. ## What makes it different | | RevenueDot | |---|---| | Source code | Open source: the server and dashboard are AGPL-3.0 ([repository](https://github.com/revenuedot/revenuedot)) | | Where it runs | Your own servers (one Docker image plus Postgres), or RevenueDot Cloud at `https://api.revenuedot.app` | | Price | Free to self-host. RevenueDot Cloud is live with open sign-up, and every account is on the free plan | | App changes | Set the SDK's proxy URL and turn off its response-signature check, or install the RevenueDot fork of the SDK | | Data | Self-hosted: purchases, customers and receipts stay in your own Postgres | RevenueDot is not affiliated with, endorsed by or sponsored by RevenueCat, Inc. "RevenueCat" is a trademark of RevenueCat, Inc. and is used here only to describe compatibility. ## How the pieces fit ```text Your app (RevenueCat SDK, proxyURL = your server) │ GET /v1/subscribers/{id}, GET .../offerings, POST /v1/receipts ▼ RevenueDot server ◀──── App Store Server Notifications v2, Google Play real-time notifications │ Postgres (customers, purchases, events) ├──▶ Webhooks to your backend (INITIAL_PURCHASE, RENEWAL, EXPIRATION ...) └──▶ Dashboard and REST API (catalog, customers, keys) ``` 1. The app asks RevenueDot for offerings and customer info. 2. The user buys through the App Store, Google Play or the built-in **Test Store**. 3. The SDK posts the purchase to `POST /v1/receipts`. RevenueDot verifies it with the store and answers with updated customer info, including active entitlements. 4. Later changes, such as renewals and refunds, arrive as store notifications. RevenueDot updates the customer and sends webhooks. ## Where to go next - **Try it in 5 minutes:** [Quickstart](https://revenuedot.app/docs/getting-started/quickstart.md), or sign up for RevenueDot Cloud at [app.revenuedot.app](https://app.revenuedot.app). - **Choose how your app connects:** [proxy mode, fork packages or your existing keys](https://revenuedot.app/docs/getting-started/connect-your-app.md). - **Learn the model:** [Concepts](https://revenuedot.app/docs/concepts.md). - **Move a live app:** [Migrate from RevenueCat](https://revenuedot.app/docs/migrate.md). - **Connect your SDK:** [SDK guides](https://revenuedot.app/docs/sdks.md). - **Run it for real:** [Self-hosting](https://revenuedot.app/docs/guides/self-hosting.md) and [Going to production](https://revenuedot.app/docs/guides/going-to-production.md). - **Look something up:** [API reference](https://revenuedot.app/docs/api.md), [Help center](https://revenuedot.app/docs/help.md), [Blog](https://revenuedot.app/blog.md). --- # How do I run RevenueDot and make a first purchase in 5 minutes? Source: https://revenuedot.app/docs/getting-started/quickstart.md Description: Start the server with Docker Compose, seed a Test Store app with one script, buy a subscription with curl, then point the RevenueCat SDK at your server. Start the server with Docker, seed it with a Test Store app, make a purchase with `curl`, then point an SDK at it. You need no App Store or Google Play account. Most of the 5 minutes is the first image build. You need Docker with Compose v2, plus `git`, `curl` and `jq`. To skip running a server, sign up for RevenueDot Cloud at [app.revenuedot.app](https://app.revenuedot.app) and use `https://api.revenuedot.app` wherever this page says `http://localhost:8787`. ## 1. Start RevenueDot ```bash git clone https://github.com/revenuedot/revenuedot.git cd revenuedot cp .env.example .env # sets POSTGRES_PASSWORD; change it before the first start docker compose up -d # builds the image, starts the server and Postgres curl http://localhost:8787/v1/health ``` ```json {"status":"ok"} ``` One container serves the SDK API, the REST API and the dashboard on port 8787. To use another host port, set `REVENUEDOT_PORT=8797` in `.env`. The dashboard is at `http://localhost:8787/login`; the bare `/` path returns a small JSON document. ## 2. Seed a project with a Test Store app The seed script uses only the public API. It creates a dashboard account and project, a Test Store app, three products, a `pro` entitlement, a `default` offering and a secret key. ```bash curl -fsSLO https://raw.githubusercontent.com/revenuedot/examples/main/selfhost/docker-compose/seed.sh bash seed.sh # RD_URL=http://localhost:8797 bash seed.sh for another port ``` ```text RevenueDot is seeded. Dashboard http://localhost:8787 (sign in as dev@example.com) Project id projqhf9p5jb Test Store key test_960d2b3001bac439c7a73d7f4214514c <- the SDK's API key; the SDK's proxy URL is http://localhost:8787 Secret key sk_8b8dc12a743e51f4de759b0b9c6d372e6d25d32fe7280672 <- server-side only (REST API, backend checks) Try it: curl -s -H "Authorization: Bearer test_960d2b3001bac439c7a73d7f4214514c" http://localhost:8787/v1/subscribers/user_1/offerings ``` Save the two keys for the next steps: ```bash export TEST_KEY=test_... # the Test Store key printed above export SECRET_KEY=sk_... # the secret key printed above ``` To see what the script does, or to do it by hand, read [the seed script](https://github.com/revenuedot/examples/blob/main/selfhost/docker-compose/seed.sh). Each step is one REST API v2 call. You can also do everything in the dashboard. ## 3. Ask for offerings, as the SDK does ```bash curl -s -H "Authorization: Bearer $TEST_KEY" http://localhost:8787/v1/subscribers/user_1/offerings ``` ```json {"current_offering_id":"default","offerings":[{"description":"Standard plans","identifier":"default","metadata":null,"packages":[{"identifier":"$rc_monthly","platform_product_identifier":"pro_monthly"},{"identifier":"$rc_annual","platform_product_identifier":"pro_annual"},{"identifier":"$rc_lifetime","platform_product_identifier":"pro_lifetime"}]}]} ``` ## 4. Make a Test Store purchase In an app, the SDK makes this call for you after the user confirms the Test Store dialog. The Test Store accepts any `fetch_token` of the form `test__`. ```bash curl -s http://localhost:8787/v1/receipts \ -H "Authorization: Bearer $TEST_KEY" -H "Content-Type: application/json" \ -d "{\"app_user_id\":\"user_1\",\"fetch_token\":\"test_$(date +%s)000_quickstart\",\"product_id\":\"pro_monthly\",\"price\":9.99,\"currency\":\"USD\"}" ``` The answer is the customer's info. `pro` is now in `entitlements`, active until one month from now: ```json { "request_date": "2026-09-30T19:49:29Z", "subscriber": { "entitlements": { "pro": { "expires_date": "2026-10-30T19:49:29Z", "grace_period_expires_date": null, "product_identifier": "pro_monthly", "purchase_date": "2026-09-30T19:49:29Z" } }, "original_app_user_id": "user_1", "subscriptions": { "pro_monthly": { "store": "test_store", "is_sandbox": true, "period_type": "normal", "expires_date": "2026-10-30T19:49:29Z", "...": "..." } } }, "purchased_products": { "pro_monthly": { "should_consume": false } } } ``` Check the same customer through the REST API with the secret key: ```bash curl -s -H "Authorization: Bearer $SECRET_KEY" \ "http://localhost:8787/v2/projects//customers/user_1/active_entitlements" ``` ## 5. Point an SDK at your server Use the Test Store key as the SDK's API key and your server as its proxy URL. The shortest path is the [purchases-js web example](https://github.com/revenuedot/examples/tree/main/web/purchases-js-vite), which runs in a browser in two minutes: ```bash git clone https://github.com/revenuedot/examples.git && cd examples/web/purchases-js-vite npm install printf "VITE_REVENUEDOT_URL=http://localhost:8787\nVITE_REVENUEDOT_API_KEY=$TEST_KEY\n" > .env.local npm run dev # open http://localhost:5199, click Buy, then "Test valid purchase" ``` In your own app, the change is the same on every platform: set the proxy URL before configuring, and turn off the response-signature check where the SDK has one. ```swift // iOS: Point the SDK at your RevenueDot server; nothing else in the app changes. Purchases.proxyURL = URL(string: "http://localhost:8787")! Purchases.configure(with: Configuration.Builder(withAPIKey: "test_...").with(entitlementVerificationMode: .disabled).build()) ``` ```kotlin // Android: Point the SDK at your RevenueDot server; nothing else in the app changes. Purchases.proxyURL = URL("http://10.0.2.2:8787") // the emulator reaches your computer as 10.0.2.2 Purchases.configure(PurchasesConfiguration.Builder(context, "test_...").entitlementVerificationMode(EntitlementVerificationMode.DISABLED).build()) ``` ```ts // React Native / Expo: Point the SDK at your RevenueDot server; nothing else in the app changes. await Purchases.setProxyURL("http://localhost:8787"); Purchases.configure({ apiKey: "test_..." }); ``` Per-platform details, including Flutter, Capacitor, Kotlin Multiplatform, Unity and Cordova, are in the [SDK guides](https://revenuedot.app/docs/sdks.md). To use the RevenueDot fork packages instead, see [How do I connect my app?](https://revenuedot.app/docs/getting-started/connect-your-app.md). Give each Test Store product a price in the dashboard (Product catalog, Edit product, Test Store price) so the paywall shows it; a product without one shows 0. Real prices come from the App Store and Google Play. See [Test Store](https://revenuedot.app/docs/guides/test-store.md). ## Next steps - Receive the purchase on your backend: [Webhooks](https://revenuedot.app/docs/guides/webhooks.md). - Simulate renewals, cancellations and refunds without waiting: [Test Store](https://revenuedot.app/docs/guides/test-store.md). - Learn how entitlements, offerings and customers fit together: [Concepts](https://revenuedot.app/docs/concepts.md). - Connect a real store: [App Store](https://revenuedot.app/docs/guides/app-store.md), [Google Play](https://revenuedot.app/docs/guides/google-play.md). - Move an existing app: [Migrate from RevenueCat](https://revenuedot.app/docs/migrate.md). --- # How do I connect my app to RevenueDot? Source: https://revenuedot.app/docs/getting-started/connect-your-app.md Description: Three ways. Proxy mode sets one URL in the RevenueCat SDK you already ship. The RevenueDot fork swaps the package. The importer keeps your existing RevenueCat API keys working. Set the RevenueCat SDK's proxy URL to RevenueDot (`https://api.revenuedot.app` for RevenueDot Cloud, or your own server) and turn its signature check off. That is **proxy mode**, and it works today with every SDK. Later you can swap in the **RevenueDot fork** of the SDK, which verifies RevenueDot's response signatures. When you migrate, the importer lets old app versions keep their **existing RevenueCat API keys**. | Way | What you change in the app | What you get | Status (2026-09-30) | |---|---|---|---| | [Proxy mode](https://revenuedot.app/docs/getting-started/connect-your-app.md#proxy-mode-one-setting-in-the-sdk-you-already-ship) | One setting: the proxy URL, plus the verification mode | Every SDK call goes to your server | Works with all 10 SDKs | | [Fork packages](https://revenuedot.app/docs/getting-started/connect-your-app.md#fork-packages-a-package-swap-no-code-change) | The package name in your dependency file | Signed responses verify; no traffic to RevenueCat's hosts | Forked and patched; not on package registries yet | | [Keep your RevenueCat keys](https://revenuedot.app/docs/getting-started/connect-your-app.md#keep-your-revenuecat-api-keys) | Nothing | App versions already in users' hands keep working | Works (importer) | ## Proxy mode: one setting in the SDK you already ship ```swift // iOS. Point the SDK at your RevenueDot server; nothing else in the app changes. Purchases.proxyURL = URL(string: "https://revenuedot.example.com")! Purchases.configure(with: Configuration.Builder(withAPIKey: "appl_...").with(entitlementVerificationMode: .disabled).build()) ``` ```kotlin // Android. Point the SDK at your RevenueDot server; nothing else in the app changes. Purchases.proxyURL = URL("https://revenuedot.example.com") Purchases.configure(PurchasesConfiguration.Builder(context, "goog_...").entitlementVerificationMode(EntitlementVerificationMode.DISABLED).build()) ``` ```ts // React Native and Expo. Point the SDK at your RevenueDot server; nothing else in the app changes. await Purchases.setProxyURL("https://revenuedot.example.com"); Purchases.configure({ apiKey: Platform.OS === "ios" ? "appl_..." : "goog_..." }); ``` - **Set the proxy URL before `configure`.** The SDK reads it once. For RevenueDot Cloud it is `https://api.revenuedot.app`. - **Turn entitlement verification off.** The stock SDK checks responses against RevenueCat's signing key. RevenueDot cannot sign with that key, so iOS and Android would report every response as `FAILED` in their default informational mode. Access still works, but the logs fill with errors. See [Trusted Entitlements](https://revenuedot.app/docs/guides/trusted-entitlements.md). - **Use the keys RevenueDot gives each app** (`appl_`, `goog_`, `test_` ...), or keep your RevenueCat keys as described below. - **Known limits.** The stock Android SDK still sends diagnostics, paywall events and ad events to RevenueCat's hosts. The stock purchases-js sends analytics to RevenueCat unless you set `flags: { collectAnalyticsEvents: false }`. Flutter's web build ignores the proxy URL. Every SDK's exact call is in its guide: [iOS](https://revenuedot.app/docs/sdks/ios.md), [Android](https://revenuedot.app/docs/sdks/android.md), [React Native](https://revenuedot.app/docs/sdks/react-native.md), [Flutter](https://revenuedot.app/docs/sdks/flutter.md), [Web](https://revenuedot.app/docs/sdks/web.md), [Capacitor](https://revenuedot.app/docs/sdks/capacitor.md), [Kotlin Multiplatform](https://revenuedot.app/docs/sdks/kotlin-multiplatform.md), [Unity](https://revenuedot.app/docs/sdks/unity.md), [Cordova](https://revenuedot.app/docs/sdks/cordova.md). ## Fork packages: a package swap, no code change RevenueDot maintains MIT forks of all ten RevenueCat SDKs. They keep every name your code imports (`import RevenueCat`, `com.revenuecat.purchases.*`, `package:purchases_flutter`), so the swap is a dependency change. Each fork: - trusts RevenueDot's response-signing key, so entitlement verification reports `VERIFIED` against a server that signs; - sends diagnostics and events to the proxy URL too (Android, purchases-js), and makes the proxy URL work on Flutter web; - defaults to `https://api.revenuedot.app` (RevenueDot Cloud), so Cloud projects need no proxy URL and self-hosters still set theirs. ```jsonc // package.json (React Native): the alias keeps `import Purchases from "react-native-purchases"` working. "react-native-purchases": "npm:@revenuedot/react-native-purchases@10.10.2" ``` The forks are not on npm, Maven Central, CocoaPods or OpenUPM yet. Each SDK guide gives the planned package name and how to use the fork's `revenuedot/main-patches` branch today. **A self-hosted server signs with its own key**, so the official forks only verify against RevenueDot Cloud. To verify against your own server, build the forks with your key; see [Trusted Entitlements](https://revenuedot.app/docs/guides/trusted-entitlements.md#verify-against-your-own-server). ## Keep your RevenueCat API keys Apps already in the store send their RevenueCat public key (`appl_...`, `goog_...`). The [importer](https://revenuedot.app/docs/migrate/importer.md) sets each RevenueDot app's public key to that same string, so old app versions work against RevenueDot the moment you point traffic at it. You can also do it for one app with the API: ```bash curl -s -X POST "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/import/apps/$APP_ID/public_key" \ -H "Authorization: Bearer $SECRET_KEY" -H "Content-Type: application/json" \ -d '{"public_key":"appl_YourExistingRevenueCatKey"}' ``` Old app versions still call RevenueCat's API until they update, because the proxy URL lives in the app. That is why a migration runs both systems side by side for a while. See [Migrate from RevenueCat](https://revenuedot.app/docs/migrate.md). ## Which should I pick? - **Trying RevenueDot or self-hosting:** proxy mode with verification off. - **You rely on entitlement verification:** fork packages, built with your server's key when you self-host. - **Moving a live app:** proxy mode in the next release, your RevenueCat keys through the importer, and a [dual run](https://revenuedot.app/docs/migrate/dual-run.md) until most users have updated. ## Related - [Quickstart](https://revenuedot.app/docs/getting-started/quickstart.md) - [SDK guides](https://revenuedot.app/docs/sdks.md) - [Which key goes where](https://revenuedot.app/docs/concepts/projects-and-apps.md#which-key-goes-where) - [Why we forked the RevenueCat SDKs](https://revenuedot.app/blog/why-we-forked-the-revenuecat-sdks.md) === Concepts === # How is RevenueDot organized? Source: https://revenuedot.app/docs/concepts.md Description: A server holds projects. A project holds apps (one per store), a catalog of products, entitlements, offerings and packages, and customers with their purchases and events. A RevenueDot server holds **projects**. A project holds **apps** (one per store), a **catalog** (products, entitlements, offerings, packages) and **customers** (with their subscriptions, one-time purchases and events). Your app checks **entitlements**, not products. ```text Project "Scanner" ├── Apps App Store app (appl_ key) · Google Play app (goog_ key) · Test Store app (test_ key) ├── Products pro_monthly (App Store) · pro:monthly (Google Play) · pro_monthly (Test Store) · lifetime ├── Entitlements pro ← unlocked by any of the products above ├── Offerings default (current) → packages $rc_monthly, $rc_annual → one product per app └── Customers user_42 (aliases: $RCAnonymousID:ab12…, user_42) ├── subscriptions and one-time purchases, per store └── events: INITIAL_PURCHASE, RENEWAL, CANCELLATION, EXPIRATION … ``` ## The pages in this section | Page | What it explains | |---|---| | [Projects, apps and API keys](https://revenuedot.app/docs/concepts/projects-and-apps.md) | What a project is, which app types exist, and which key goes where | | [Products and entitlements](https://revenuedot.app/docs/concepts/products-and-entitlements.md) | How store products map to the access your app checks | | [Offerings and packages](https://revenuedot.app/docs/concepts/offerings-and-packages.md) | How the paywall's contents are chosen without an app update | | [Customers and app user IDs](https://revenuedot.app/docs/concepts/customers-and-app-user-ids.md) | Anonymous IDs, `logIn`, aliases, and who owns a restored purchase | | [Subscriptions and events](https://revenuedot.app/docs/concepts/subscriptions-and-events.md) | Lifecycle states, grace periods, refunds, and the events each change produces | | [Sandbox and production](https://revenuedot.app/docs/concepts/sandbox.md) | How test purchases are kept apart from real ones | ## Words used across the docs | Word | Meaning | |---|---| | **Proxy URL** | The SDK setting that sends every SDK request to your RevenueDot server instead of RevenueCat's API | | **Public app key** | A per-app key the SDK sends (`appl_`, `goog_`, `test_` …). Safe to ship in an app | | **Secret key** | A per-project key (`sk_…`) for the REST API and your backend. Never ship it in an app | | **App user ID** | The ID the SDK uses for the current user: either your own ID or an anonymous `$RCAnonymousID:…` | | **Customer info** | The JSON the SDK decodes into `CustomerInfo`: entitlements, subscriptions and one-time purchases | | **Test Store** | RevenueDot's built-in store for development. Purchases need no App Store or Google Play account and are always sandbox | | **Sandbox** | Test purchases (App Store sandbox, Google Play test purchases, the Test Store). They are kept apart from production in metrics and can be filtered for webhooks. See [Sandbox and production](https://revenuedot.app/docs/concepts/sandbox.md) | --- # What are projects, apps and API keys? Source: https://revenuedot.app/docs/concepts/projects-and-apps.md Description: A project is one product you sell. An app is that product on one store, with its own public SDK key. Secret keys belong to a project and unlock the REST API. A **project** is one product you sell, such as "Scanner". It owns the catalog, the customers, the webhooks and the secret keys. An **app** is that product on one store. Each app has its own public key, which the SDK sends with every request. ## Projects - Signing up in the dashboard (`/signup`) or with `POST /auth/signup` creates your account and its first project. - More projects: dashboard → **New project**, or `POST /v2/projects` while signed in to the dashboard. A secret key belongs to one project, so it cannot create projects. - Project settings (`GET` or `POST /v2/projects/{project_id}`): `name`, `transfer_behavior` and `sandbox_transfer_behavior`. See [Customers and app user IDs](https://revenuedot.app/docs/concepts/customers-and-app-user-ids.md#who-owns-a-restored-purchase). - Deleting a project deletes everything in it. Only an admin can do it, and only from the dashboard. ## Apps Create an app per store with `POST /v2/projects/{project_id}/apps` or on the dashboard's **Apps** page. | `type` | Store | Public key prefix | Store id field | Receipts accepted today | |---|---|---|---|---| | `app_store` | Apple App Store (iOS, iPadOS, tvOS, visionOS, watchOS) | `appl_` | `app_store.bundle_id` | Yes | | `mac_app_store` | Mac App Store | `mac_` | `mac_app_store.bundle_id` | Yes | | `play_store` | Google Play | `goog_` | `play_store.package_name` | Yes | | `test_store` | RevenueDot Test Store | `test_` | none | Yes | | `amazon` | Amazon Appstore | `amzn_` | `amazon.package_name` | No (Tier 2) | | `stripe`, `rc_billing`, `paddle`, `roku` | Web and other stores | `strp_`, `rcb_`, `pdl_`, `roku_` | none | No (planned) | ```bash curl -s -X POST http://localhost:8787/v2/projects/$PROJECT_ID/apps \ -H "Authorization: Bearer $SECRET_KEY" -H "Content-Type: application/json" \ -d '{"name":"Scanner (iOS)","type":"app_store","app_store":{"bundle_id":"com.example.scanner"}}' ``` ```json {"object":"app","id":"appk6jbcorn","name":"Scanner (iOS)","created_at":1790798214712,"type":"app_store","project_id":"projujvzn2wl","custom_url_scheme":"rc-3c3d62a884","app_store":{"bundle_id":"com.example.scanner","app_store_connect_api_key_configured":false,"subscription_key_configured":false,"app_store_connect_vendor_number":null}} ``` Store credentials, such as Apple's in-app purchase key and Google's service account, belong to the app. They are never returned by the API; responses only say whether each is configured. Setup is in [Connect the App Store](https://revenuedot.app/docs/guides/app-store.md) and [Connect Google Play](https://revenuedot.app/docs/guides/google-play.md). ## Which key goes where | Key | Looks like | Where it lives | What it can do | |---|---|---|---| | Public app key | `appl_…`, `goog_…`, `test_…` | In your app, passed to `Purchases.configure` | The [SDK endpoints](https://revenuedot.app/docs/api/sdk-endpoints.md) for that app's project: customer info, offerings, receipts, logIn, attributes | | Secret key | `sk_…` | Your backend and scripts only | [REST API v1](https://revenuedot.app/docs/api/rest-v1.md) and [v2](https://revenuedot.app/docs/api/rest-v2.md) for one project, limited by its permissions | | Dashboard session | cookie `rd_session` | Your browser | The dashboard, and `/v2` for every project you are a member of | | Webhook signing secret | `whsec_…` | Your backend | Verifying webhook signatures | Get an app's public key with `GET /v2/projects/{project_id}/apps/{app_id}/public_api_keys` or on the app's dashboard page. Create secret keys on **API keys** or with `POST /v2/projects/{project_id}/api_keys`. The key is returned once. See [Authentication](https://revenuedot.app/docs/api/authentication.md). ## Related - [Products and entitlements](https://revenuedot.app/docs/concepts/products-and-entitlements.md) - [REST API v2: apps](https://revenuedot.app/docs/api/rest-v2.md#apps) --- # How do products and entitlements work? Source: https://revenuedot.app/docs/concepts/products-and-entitlements.md Description: A product is one item on one store. An entitlement is the access your app checks, such as pro. Attach every product that should unlock it; the app then checks the entitlement, never a product id. A **product** is one thing a store sells, such as `pro_monthly` on the App Store. An **entitlement** is the access your app checks, such as `pro`. You attach products to an entitlement, and any purchase of any attached product unlocks it. Your app checks `customerInfo.entitlements["pro"]`, so adding a yearly plan or a Google Play product later needs no app update. ```text Entitlement "pro" ├── pro_monthly App Store app subscription P1M ├── pro_annual App Store app subscription P1Y ├── pro:monthly Google Play app subscription (base plan "monthly") └── pro_lifetime Test Store app non_consumable ``` ## Products A product belongs to one app. Create it in the dashboard (**Products**) or with the API: ```bash curl -s -X POST "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/products" \ -H "Authorization: Bearer $SECRET_KEY" -H "Content-Type: application/json" \ -d '{"store_identifier":"pro_monthly","app_id":"'$APP_ID'","type":"subscription","display_name":"Pro monthly","subscription":{"duration":"P1M"}}' ``` | Field | What to put there | |---|---| | `store_identifier` | The store's product id, exactly as in App Store Connect or Play Console. For Google Play subscriptions use `subscriptionId:basePlanId`, for example `pro:monthly`. A plain `pro` also matches every base plan of that subscription | | `type` | `subscription`, `non_consumable` (a lifetime unlock), `consumable` (coins, credits), `non_renewing_subscription` or `one_time` | | `subscription.duration` | ISO 8601 period: `P1W`, `P1M`, `P3M`, `P6M`, `P1Y`, or any other such as `P3D`. The Test Store uses it as the period length, and MRR uses it for every store | | `display_name` | Your own label for the dashboard | - **Store ids must match.** RevenueDot matches purchases to products by `store_identifier`. A purchase of an unknown product is still saved and still appears in customer info, but it unlocks nothing. - **Consumables never unlock an entitlement.** The Android SDK consumes them when RevenueDot answers `should_consume: true`, so they can be bought again. - **Archive instead of delete** when a product is retired: `POST .../products/{product_id}/actions/archive`. Archived products leave offerings. Deleting a product detaches it from entitlements and packages; purchase history keeps the store id. ## Entitlements ```bash curl -s -X POST "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/entitlements" \ -H "Authorization: Bearer $SECRET_KEY" -H "Content-Type: application/json" \ -d '{"lookup_key":"pro","display_name":"Pro access"}' curl -s -X POST "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/entitlements/$ENTITLEMENT_ID/actions/attach_products" \ -H "Authorization: Bearer $SECRET_KEY" -H "Content-Type: application/json" \ -d '{"product_ids":["'$MONTHLY_ID'","'$ANNUAL_ID'"]}' ``` The `lookup_key` is the name apps see. The entitlement's `id` (`entl...`) is what REST API v2 uses, for example in `grant_entitlement`. ## How RevenueDot decides whether an entitlement is active RevenueDot computes entitlements from every purchase the customer owns. The rules come from `packages/core/src/entitlements.ts`: 1. **Each attached purchase is a candidate.** Subscriptions, non-consumable one-time purchases and promotional grants count. Consumables do not. 2. **Access ends at the right time.** For a subscription, access ends at the refund time if it was refunded, else at the end of the grace period if the store granted one, else at the store's expiry. A lifetime purchase never ends unless it is refunded. 3. **The best candidate wins.** A lifetime unlock beats everything. Otherwise the purchase whose access ends last wins. 4. **Expired entitlements stay listed.** Customer info lists every entitlement a customer ever had, with its `expires_date`. The SDK's `isActive` compares that date with the server time, so an entitlement with a past `expires_date` is inactive. ```json "entitlements": { "pro": { "expires_date": "2026-10-30T20:41:54Z", "grace_period_expires_date": null, "product_identifier": "pro_monthly", "purchase_date": "2026-09-30T20:41:54Z" } } ``` ## Give access without a purchase Grant promotional access to a customer, for example for support or a partnership. It appears as a subscription from the `promotional` store and unlocks the entitlement until it ends. ```bash # REST API v2: until a date (epoch milliseconds) curl -s -X POST "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/customers/user_42/actions/grant_entitlement" \ -H "Authorization: Bearer $SECRET_KEY" -H "Content-Type: application/json" \ -d '{"entitlement_id":"'$ENTITLEMENT_ID'","expires_at":1830000000000}' # REST API v1: by lookup key, for a fixed duration curl -s -X POST "$REVENUEDOT_URL/v1/subscribers/user_42/entitlements/pro/promotional" \ -H "Authorization: Bearer $SECRET_KEY" -H "Content-Type: application/json" -d '{"duration":"monthly"}' ``` A grant that ends within 2 hours of an existing grant for the same entitlement is treated as a duplicate and changes nothing. Revoke grants with `revoke_granted_entitlement` (v2) or `revoke_promotionals` (v1). ## Related - [Offerings and packages](https://revenuedot.app/docs/concepts/offerings-and-packages.md) - [Why is my entitlement not active?](https://revenuedot.app/docs/help/entitlement-not-active.md) - [REST API v2: products and entitlements](https://revenuedot.app/docs/api/rest-v2.md) --- # How do offerings and packages decide what my paywall shows? Source: https://revenuedot.app/docs/concepts/offerings-and-packages.md Description: An offering is a set of packages, and each package holds one product per app. The SDK shows the current offering, so you change the paywall's products on the server without an app update. The SDK asks RevenueDot for **offerings** and shows the **current** one. An offering holds **packages** such as "Monthly" and "Yearly". Each package holds one product per app, so the same offering serves your iOS and Android apps. To change prices or plans, change the current offering on the server; the app picks it up on its next `getOfferings()` call. ```text Offering "default" (current) metadata: {"headline":"Go Pro"} ├── $rc_monthly "Monthly" → pro_monthly (App Store) · pro:monthly (Google Play) ├── $rc_annual "Yearly" → pro_annual (App Store) · pro:annual (Google Play) └── $rc_lifetime "Lifetime" → pro_lifetime (App Store) ``` ## What the SDK receives `GET /v1/subscribers/{app_user_id}/offerings` answers only the packages whose product belongs to the calling app (the app is known from the public key): ```json {"current_offering_id":"default","offerings":[{"description":"Standard plans","identifier":"default","metadata":null,"packages":[{"identifier":"$rc_monthly","platform_product_identifier":"pro_monthly"},{"identifier":"$rc_annual","platform_product_identifier":"pro_annual"},{"identifier":"$rc_lifetime","platform_product_identifier":"pro_lifetime"}]}]} ``` The SDK then loads prices and titles from the store (or from RevenueDot for Test Store products) and builds `Offerings`, `Offering` and `Package` objects. For Google Play products stored as `subscription:basePlan`, the package also carries `platform_product_plan_identifier`. ## Build an offering ```bash B="$REVENUEDOT_URL/v2/projects/$PROJECT_ID"; H="Authorization: Bearer $SECRET_KEY" # 1. The offering. The project's first offering becomes current. curl -s -X POST "$B/offerings" -H "$H" -H "Content-Type: application/json" -d '{"lookup_key":"default","display_name":"Standard plans"}' # 2. A package, placed first. curl -s -X POST "$B/offerings/$OFFERING_ID/packages" -H "$H" -H "Content-Type: application/json" -d '{"lookup_key":"$rc_monthly","display_name":"Monthly","position":0}' # 3. One product per app in the package. curl -s -X POST "$B/packages/$PACKAGE_ID/actions/attach_products" -H "$H" -H "Content-Type: application/json" \ -d '{"products":[{"product_id":"'$IOS_MONTHLY'","eligibility_criteria":"all"},{"product_id":"'$ANDROID_MONTHLY'","eligibility_criteria":"all"}]}' ``` - **Use the standard package lookup keys** (`$rc_weekly`, `$rc_monthly`, `$rc_two_month`, `$rc_three_month`, `$rc_six_month`, `$rc_annual`, `$rc_lifetime`) so the SDK's shortcuts such as `offering.monthly` work. Any other key is a custom package. - **Order** follows `position`, lowest first, then creation time. - **One product per app per package.** Two products of the same app can share a package only when their `eligibility_criteria` do not overlap (`google_sdk_lt_6` and `google_sdk_ge_6`); otherwise the API answers 409. - **Metadata** is free-form JSON on the offering (`metadata`). Use it for paywall copy or feature flags; the SDK exposes it as `offering.metadata`. ## Change the paywall without an app update - **Make another offering current:** `POST .../offerings/{offering_id}` with `{"is_current": true}`. Exactly one offering is current. An archived offering cannot be made current. - **Show one customer a different offering:** `POST .../customers/{customer_id}/actions/assign_offering` with `{"offering_id": "ofrng..."}` (v2), or `POST /v1/subscribers/{app_user_id}/offerings/{offering}/override` (v1). That customer's `current_offering_id` becomes this offering. Send `null` (v2) or `DELETE /v1/subscribers/{app_user_id}/offerings/override` to undo it. - **Retire an offering:** archive it (`.../actions/archive`). The current offering cannot be archived; make another one current first. Experiments, targeting rules and server-driven paywalls are not built yet (planned for Tier 2). The SDK's paywall components get no paywall from RevenueDot today. ## Related - [Products and entitlements](https://revenuedot.app/docs/concepts/products-and-entitlements.md) - [REST API v2: offerings and packages](https://revenuedot.app/docs/api/rest-v2.md) - [SDK endpoints: offerings](https://revenuedot.app/docs/api/sdk-endpoints.md) --- # How do customers, app user IDs and logIn work? Source: https://revenuedot.app/docs/concepts/customers-and-app-user-ids.md Description: A customer is one person with one or more app user IDs. The SDK starts with an anonymous ID; logIn attaches your own ID; the project's transfer behaviour decides who owns a restored purchase. A **customer** is one person. They can have several **app user IDs**: the anonymous ID the SDK made on first launch and the ID you pass to `logIn`. Purchases belong to the customer, so they follow the person across IDs. When a purchase that already belongs to someone else is restored, the project's **transfer behaviour** decides who gets it. ## Anonymous app user IDs Without an ID, the SDK generates one like `$RCAnonymousID:4f2c9a...` and stores it on the device. RevenueDot creates the customer the first time the SDK asks for customer info (`GET /v1/subscribers/{id}` answers 201 for a new customer). - Anonymous IDs are fine for apps without accounts. Restores then move purchases to the device's current anonymous ID. - An anonymous ID is lost when the app is deleted, so give users an account if they buy on several devices. ## logIn: attach your own ID `Purchases.logIn("user_42")` calls `POST /v1/subscribers/identify` with the current and the new ID. What happens depends on who already exists (`apps/server/src/repo/customers.ts`): | Current ID | `user_42` already exists? | Result | Status | |---|---|---|---| | Anonymous, with no other IDs | No | The anonymous customer takes `user_42` as a second ID and keeps its purchases | 201 | | Anonymous, with no other IDs | Yes | The anonymous customer is merged into `user_42`, unless `user_42` already has an anonymous ID of its own | 200 | | A known ID (not anonymous) | No | A new, empty customer `user_42` | 201 | | A known ID | Yes | Switches to `user_42`; nothing is merged | 200 | `logOut` makes the SDK generate a new anonymous ID. `GET /v2/projects/{project_id}/customers/{customer_id}/aliases` lists every ID of a customer, and any of them works as `customer_id` in REST API v2. ## Who owns a restored purchase A store purchase has one owner. When a different customer posts the same receipt (a restore or `syncPurchases()` on a second account, or after reinstalling), RevenueDot applies these rules in order (`apps/server/src/services/purchases.ts`): 1. **The owner is anonymous only:** the old anonymous customer is merged into the one posting the receipt. No setting changes this. 2. **The poster is anonymous only and the owner has a real ID:** the poster's anonymous ID is merged into the owner, like logging in as them. 3. **Otherwise** the project's `transfer_behavior` decides: | `transfer_behavior` | What happens | Event | |---|---|---| | `transfer` (default) | The purchase moves to the customer who restored it | `TRANSFER` | | `transfer_if_no_active` | It moves only if the current owner has no active subscription; otherwise the receipt post fails with 7102 "receipt already in use" | `TRANSFER` when it moves | | `keep` | It stays with the first owner; the receipt post fails with 7102 | none | | `share` | The two customers are merged, so both IDs have access | none | Set it in the dashboard (**Project settings**) or with the API. `sandbox_transfer_behavior` overrides it for sandbox purchases; `null` uses the main setting. ```bash curl -s -X POST "$REVENUEDOT_URL/v2/projects/$PROJECT_ID" \ -H "Authorization: Bearer $SECRET_KEY" -H "Content-Type: application/json" \ -d '{"transfer_behavior":"transfer_if_no_active","sandbox_transfer_behavior":"transfer"}' ``` Store notifications never move a purchase: only a receipt posted from a device does. A `TRANSFER` webhook carries `transferred_from` and `transferred_to`, the ID lists of both customers. ## Attributes Customers carry key-value **attributes**. The SDK sets them with `setEmail`, `setDisplayName`, `setAttributes` and similar calls (`POST /v1/subscribers/{app_user_id}/attributes`). Keys that start with `$` are reserved names such as `$email`; RevenueDot refuses an invalid `$email` with code 7263 and saves the others. Attributes appear in webhooks as `subscriber_attributes` and in REST API v2 under `/attributes`. A null value deletes an attribute. ## Related - [How do I restore purchases?](https://revenuedot.app/docs/help/restore-purchases.md) - [Subscriptions and events](https://revenuedot.app/docs/concepts/subscriptions-and-events.md) - [SDK endpoints: identity](https://revenuedot.app/docs/api/sdk-endpoints.md) --- # How does RevenueDot track a subscription's lifecycle and which events does it send? Source: https://revenuedot.app/docs/concepts/subscriptions-and-events.md Description: RevenueDot keeps one record per subscription, updates it from receipts and store notifications, compares the old and new state, and records RevenueCat-style events such as RENEWAL and EXPIRATION. RevenueDot keeps one record per subscription and updates it whenever the device posts a receipt or the store sends a notification. After each update it compares the old state with the new one, and every difference becomes an **event** with RevenueCat's name, such as `RENEWAL` or `CANCELLATION`. Events are stored, shown in the dashboard and sent to your webhooks. ```text receipt (device) ─┐ ┌─▶ customer info (entitlements) ├─▶ subscription state ─┤ store notification┘ old vs new └─▶ events ─▶ webhooks, event log ▲ a job every 30 seconds records EXPIRATION when access runs out ``` ## One record per subscription A subscription is keyed by the store's own id, so every renewal updates the same record: - **App Store:** the `original_transaction_id`. - **Google Play:** the purchase token. - **Test Store:** the `test__` token. The record holds the product, the current period, the expiry, the grace period end, whether auto-renew is off (`unsubscribe_detected_at`), billing problems (`billing_issues_detected_at`), refunds, pauses, family sharing and the price. ## Which change produces which event The rules live in `packages/core/src/events.ts`. | What changed | Event | |---|---| | A subscription is seen for the first time (including a free trial) | `INITIAL_PURCHASE` | | A new period started: a renewal, a trial converting, a lapsed customer coming back, a recovered billing problem | `RENEWAL` (`is_trial_conversion: true` after a trial) | | Auto-renew was turned off | `CANCELLATION` with `cancel_reason` `UNSUBSCRIBE`, `BILLING_ERROR`, `PRICE_INCREASE`, `DEVELOPER_INITIATED` or `UNKNOWN` | | Auto-renew was turned back on | `UNCANCELLATION` | | The store refunded it | `CANCELLATION` with `cancel_reason: CUSTOMER_SUPPORT` and a negative price | | A refund was reversed | `REFUND_REVERSED` | | A renewal charge failed | `BILLING_ISSUE` (with `grace_period_expiration_at_ms`) | | The product changed now (upgrade), or a change is scheduled for the next renewal (downgrade, crossgrade) | `PRODUCT_CHANGE` (with `new_product_id`) | | The period got longer without a payment (App Store extension, Google Play deferral) | `SUBSCRIPTION_EXTENDED` | | A Google Play pause was scheduled | `SUBSCRIPTION_PAUSED` (with `auto_resume_at_ms`) | | The store asks for, or gets, consent to a price increase | `PRICE_INCREASE_CONSENT_REQUIRED`, `PRICE_INCREASE_CONSENT_APPROVED` | | Access ended (including the grace period) | `EXPIRATION` with `expiration_reason` | | A one-time purchase | `NON_RENEWING_PURCHASE` | | A purchase moved to another customer on restore | `TRANSFER` | `EXPIRATION` comes from a background job, not from the store: every 30 seconds (every minute on RevenueDot Cloud) the server records `EXPIRATION` for subscriptions whose access has ended and that were not refunded. The same job sends due webhooks and, once a day per Google Play app, checks Google's voided purchases for refunds. Every event's payload is on [Webhook events](https://revenuedot.app/docs/api/webhook-events.md). ## States in REST API v2 REST API v2 reports each subscription's `status`, computed from the record (`apps/server/src/routes/v2/shapes.ts`): | `status` | Meaning | `gives_access` | |---|---|---| | `trialing` | In a free trial | yes | | `active` | Paid and current | yes | | `in_grace_period` | The period ended, the charge failed, and the store grants a grace period | yes | | `in_billing_retry` | Access ended while the store still retries the charge | no | | `paused` | A Google Play subscription is paused | no | | `expired` | Access ended | no | `auto_renewal_status` is `will_renew`, `will_not_renew`, `will_change_product` or `will_pause`. ## Where changes come from - **Receipts from the device** (`POST /v1/receipts`): every purchase, restore and `syncPurchases()`. With the App Store in-app purchase key, RevenueDot asks Apple's App Store Server API for the full history and renewal state. For Google Play it reads the purchase from the Play Developer API and acknowledges it within Google's 3-day limit. - **App Store Server Notifications v2:** `SUBSCRIBED`, `DID_RENEW`, `DID_FAIL_TO_RENEW`, `GRACE_PERIOD_EXPIRED`, `DID_CHANGE_RENEWAL_STATUS`, `DID_CHANGE_RENEWAL_PREF`, `EXPIRED`, `REFUND`, `REFUND_REVERSED`, `REVOKE`, `RENEWAL_EXTENDED`, `OFFER_REDEEMED`, `ONE_TIME_CHARGE` and `PRICE_INCREASE` update the record. Other types are stored and acknowledged. See [App Store setup](https://revenuedot.app/docs/guides/app-store.md). - **Google Play real-time developer notifications:** each subscription or one-time product notification makes RevenueDot read the purchase again from the Play Developer API; voided-purchase notifications record refunds. See [Google Play setup](https://revenuedot.app/docs/guides/google-play.md). - **REST API actions:** grants, refunds, cancels, extensions and deferrals. Store notifications about a purchase RevenueDot has never seen are stored but not applied, unless the app's **Track new purchases from server-to-server notifications** setting is on. The purchase appears when the device posts its receipt. ## Money in events `price` (USD) and `price_in_purchased_currency` carry money only on `INITIAL_PURCHASE`, `RENEWAL`, `NON_RENEWING_PURCHASE`, `REFUND_REVERSED` and refunds (negative). Other events report 0. A free trial start reports 0. `price` is converted to USD at the exchange rate of the purchase date: the ECB reference rate for the about 30 currencies the ECB publishes (the last business day before it on weekends and holidays), and the [currency-api](https://github.com/fawazahmed0/exchange-api) daily rate for every other currency. ## Related - [Webhooks](https://revenuedot.app/docs/guides/webhooks.md) - [Customers and app user IDs](https://revenuedot.app/docs/concepts/customers-and-app-user-ids.md) - [Test Store](https://revenuedot.app/docs/guides/test-store.md): produce each event on purpose --- # How does RevenueDot keep sandbox purchases apart from real ones? Source: https://revenuedot.app/docs/concepts/sandbox.md Description: Every purchase carries an environment, sandbox or production, taken from the store. Metrics count production by default, and each webhook can receive one environment or both. Every purchase and every event carries an **environment**: `sandbox` or `production`. RevenueDot takes it from the store, so you never set it yourself. Sandbox purchases unlock entitlements exactly like real ones, but metrics count production only by default, and each webhook chooses which environment it receives. ## Where sandbox purchases come from | Source | Environment | |---|---| | App Store sandbox accounts and TestFlight | `sandbox` (from Apple's signed transaction) | | StoreKit testing in Xcode | `sandbox`, accepted only when the app has the StoreKit test certificate (`xcode_certificate`) | | Google Play license testers and test tracks | `sandbox` (Google's test purchase flag) | | The RevenueDot [Test Store](https://revenuedot.app/docs/guides/test-store.md) (`test_` keys) | always `sandbox` | | Real purchases | `production` | ## What changes with the environment - **Customer info:** each subscription has `is_sandbox`. Entitlements do not care about the environment. - **Webhooks:** `event.environment` is `SANDBOX` or `PRODUCTION`. A webhook with `environment: "production"` receives only real events; `null` receives both. Use a separate webhook URL for sandbox while you develop. - **Metrics:** `GET /v2/projects/{project_id}/metrics/overview` counts production. Add `?environment=sandbox` to see test purchases (a RevenueDot extension). - **REST API v2:** subscriptions and purchases have `environment`, and customer lists of subscriptions, purchases and events accept `?environment=`. - **Restores:** a project can use a different transfer behaviour for sandbox purchases (`sandbox_transfer_behavior`). See [who owns a restored purchase](https://revenuedot.app/docs/concepts/customers-and-app-user-ids.md#who-owns-a-restored-purchase). - **Store notifications:** App Store Connect has one production URL and one sandbox URL. Point both at the same RevenueDot notification URL; each notification says its environment. ## Related - [Test Store](https://revenuedot.app/docs/guides/test-store.md) - [Test with App Store sandbox and Google Play testers](https://revenuedot.app/docs/guides/sandbox-testing.md) - [How do I test purchases without real money?](https://revenuedot.app/docs/help/test-sandbox-purchases.md) === SDK guides === # Which RevenueCat SDKs work with RevenueDot? Source: https://revenuedot.app/docs/sdks.md Description: All nine RevenueCat app SDKs work with RevenueDot in proxy mode today. Set the proxy URL and turn off signature checks. RevenueDot forks of all ten SDK repos exist but are not published yet. All nine RevenueCat app SDKs work with RevenueDot today in **proxy mode**: you keep the SDK you ship, set one proxy URL before `configure`, and turn off signature checks. RevenueDot also keeps a fork of each SDK repo (ten in total, counting the shared hybrid layer). The forks keep every import name, but **none of them is published to a package registry yet** (as of 2026-09-30). ## Pick a mode - **Proxy mode** works now. It is one setting in your app. The stock SDK checks response signatures against RevenueCat's key, so every RevenueDot response reads as "verification failed" unless you turn the check off. See [Trusted Entitlements](https://revenuedot.app/docs/guides/trusted-entitlements.md). - **Fork packages** will replace the stock package without code changes once they are published. They trust RevenueDot's signing key and send every request, including analytics and diagnostics, to your server. - **Keep your RevenueCat keys**: the [importer](https://revenuedot.app/docs/migrate/importer.md) copies each app's public key into RevenueDot, so builds you already shipped keep the same `appl_` or `goog_` key. See [Connect your app](https://revenuedot.app/docs/getting-started/connect-your-app.md). ## Every SDK at a glance | SDK | Proxy mode today | How to set the proxy URL | Signature check default (stock SDK) | Planned fork package | Guide | |---|---|---|---|---|---| | iOS, macOS, tvOS, watchOS, visionOS | Yes | `Purchases.proxyURL = URL(string: "...")!` | Informational: logs a failure, still grants access. Set `.disabled` | CocoaPods `RevenueDotPurchases`, SPM `github.com/revenuedot/purchases-ios` | [iOS](https://revenuedot.app/docs/sdks/ios.md) | | Android | Yes. Diagnostics, paywall events and ad events still go to RevenueCat | `Purchases.proxyURL = URL("...")` | Informational. Set `DISABLED` | Maven `app.revenuedot.purchases:purchases` | [Android](https://revenuedot.app/docs/sdks/android.md) | | React Native and Expo | Yes, on iOS, Android, Expo Go and web | `await Purchases.setProxyURL("...")` | Disabled | npm `@revenuedot/react-native-purchases` | [React Native](https://revenuedot.app/docs/sdks/react-native.md) | | Flutter | Yes on iOS and Android. **Not on Flutter web** with the stock SDK | `await Purchases.setProxyURL('...')` | Disabled | git `github.com/revenuedot/purchases-flutter` | [Flutter](https://revenuedot.app/docs/sdks/flutter.md) | | Web (purchases-js) | Yes, with Test Store (`test_`) keys only | `httpConfig: { proxyURL: "..." }` in `configure` | No signature checks | npm `@revenuedot/purchases-js` | [Web](https://revenuedot.app/docs/sdks/web.md) | | Capacitor and Ionic | Yes | `await Purchases.setProxyURL({ url: "..." })` | No default passed, so the native informational default applies. Pass `DISABLED` | npm `@revenuedot/purchases-capacitor` | [Capacitor](https://revenuedot.app/docs/sdks/capacitor.md) | | Kotlin Multiplatform | Yes | `Purchases.proxyURL = "..."` (a `String`) | Disabled | Maven `app.revenuedot.purchases:purchases-kmp-core` | [Kotlin Multiplatform](https://revenuedot.app/docs/sdks/kotlin-multiplatform.md) | | Unity | Yes | The **Proxy URL** field on the Purchases component | Informational. Set **Disabled** in the Inspector | OpenUPM `com.revenuedot.purchases-unity` | [Unity](https://revenuedot.app/docs/sdks/unity.md) | | Cordova | Yes | `Purchases.setProxyURL("...")` before `configureWith` | No option: logs a failure, still grants access | npm `@revenuedot/cordova-plugin-purchases` | [Cordova](https://revenuedot.app/docs/sdks/cordova.md) | | purchases-hybrid-common | Not installed by apps | Wrappers pass the proxy URL through it | Wrappers pass the mode through it | pods `RevenueDotPurchasesHybridCommon`, Maven `app.revenuedot.purchases:purchases-hybrid-common` | [Hybrid common](https://revenuedot.app/docs/sdks/hybrid-common.md) | **Never use `ENFORCED` mode with a stock SDK against RevenueDot.** Every call would fail, because RevenueDot cannot sign with RevenueCat's key. ## What the forks change Each fork lives in `github.com/revenuedot/` on the branch `revenuedot/main-patches`, which is the upstream code plus one patch commit. The patches change only these things: - The default API host becomes `https://api.revenuedot.app`, which is RevenueDot Cloud (live since 2026-09-30). Self-hosters still set the proxy URL. - The SDK trusts RevenueDot's response-signing key instead of RevenueCat's. - Registry names change (table above). Module and package names that your code imports stay the same, so `import RevenueCat` and `com.revenuecat.purchases.*` still work. - Android: diagnostics, paywall events and ad events follow the proxy URL. - purchases-js: analytics events follow `httpConfig.proxyURL`, and checkout reads "Secure checkout by RevenueDot". - Flutter web: `Purchases.setProxyURL` works. - Each README gets a banner saying the fork is maintained by RevenueDot and not affiliated with RevenueCat. Source: [`prd/sdk-forks/PRD.md`](https://github.com/revenuedot/revenuedot/blob/main/prd/sdk-forks/PRD.md). ## What is tested today - The web SDK fork ran end to end against a real RevenueDot server: configure, customer info, offerings, a Test Store purchase, and the `pro` entitlement turning active. - The unmodified RevenueCat iOS SDK 5.92 on an iPhone simulator and Android SDK 10.24 on an Android emulator pass configure, customer info, offerings, a Test Store purchase and `logIn` against RevenueDot ([`scripts/e2e`](https://github.com/revenuedot/revenuedot/tree/main/scripts/e2e)). - Purchases through the real App Store and Google Play sandboxes have not run end to end yet. Store handling is tested against mocked Apple and Google APIs. - The [React Native Expo example](https://github.com/revenuedot/examples/tree/main/mobile/react-native-expo) and the [purchases-js Vite example](https://github.com/revenuedot/examples/tree/main/web/purchases-js-vite) run against RevenueDot with a Test Store key. ## Related - [Connect your app](https://revenuedot.app/docs/getting-started/connect-your-app.md) - [Trusted Entitlements](https://revenuedot.app/docs/guides/trusted-entitlements.md) - [Test Store](https://revenuedot.app/docs/guides/test-store.md) - [SDK changes when you migrate](https://revenuedot.app/docs/migrate/sdk-changes.md) - [SDK endpoints](https://revenuedot.app/docs/api/sdk-endpoints.md) --- # How do I use RevenueDot with the iOS SDK? Source: https://revenuedot.app/docs/sdks/ios.md Description: Set Purchases.proxyURL before configure and turn entitlement verification off. The RevenueCat iOS SDK 5.x then talks to your RevenueDot server with no other code change. Set `Purchases.proxyURL` to your RevenueDot server before you call `Purchases.configure`, and set the entitlement verification mode to `.disabled`. The rest of your RevenueCat iOS SDK code stays the same. This covers iOS, iPadOS, macOS, tvOS, watchOS and visionOS apps on SDK 5.x. ## Use the RevenueCat SDK you already ship (proxy mode) ```swift import RevenueCat // Point the SDK at your RevenueDot server; nothing else in the app changes. Purchases.proxyURL = URL(string: "https://revenuedot.example.com")! Purchases.configure( with: Configuration.Builder(withAPIKey: "appl_...") // The default (.informational) logs every RevenueDot response as a failed signature check. .with(entitlementVerificationMode: .disabled) .build() ) ``` - Set `proxyURL` **before** `configure`. It is a static property on `Purchases`. - Use the app's public key from RevenueDot (`appl_...`, or `mac_...` for a Mac App Store app). If you ran the [importer](https://revenuedot.app/docs/migrate/importer.md), your existing RevenueCat key keeps working. See [Which key goes where](https://revenuedot.app/docs/concepts/projects-and-apps.md#which-key-goes-where). - For local testing, the simulator reaches your Mac at `http://localhost:8787`. **After you migrate from RevenueCat, call `syncPurchases()` once** on the first launch of the update. It sends the device's existing App Store purchases to RevenueDot, so current subscribers keep access even if their history was not imported. ```swift // Once, after this update: send purchases made while the app talked to RevenueCat. if !UserDefaults.standard.bool(forKey: "revenuedotSynced") { _ = try? await Purchases.shared.syncPurchases() UserDefaults.standard.set(true, forKey: "revenuedotSynced") } ``` ## Use the RevenueDot fork The fork is [github.com/revenuedot/purchases-ios](https://github.com/revenuedot/purchases-ios). It keeps the Swift modules `RevenueCat` and `RevenueCatUI`, so every `import RevenueCat` stays. It trusts RevenueDot's signing key, and its default host is `https://api.revenuedot.app`. **It is not published yet (2026-09-30).** These are the planned install lines: ```ruby # Podfile (planned; the pods are not on CocoaPods trunk yet) pod 'RevenueDotPurchases', '' pod 'RevenueDotPurchasesUI', '' # only if you use RevenueCatUI ``` ```swift // Package.swift (planned release tags look like 5.92.0-revenuedot) .package(url: "https://github.com/revenuedot/purchases-ios", exact: "-revenuedot") // Products: "RevenueCat" and "RevenueCatUI" ``` **You can use it today from the patch branch.** The branch is `revenuedot/main-patches` (upstream 5.92.0 in development plus the RevenueDot patches). A second branch, `revenuedot/release-5.91.0`, is the 5.91.0 release plus the patches. ```swift // Swift Package Manager, from the branch .package(url: "https://github.com/revenuedot/purchases-ios", branch: "revenuedot/main-patches") ``` ```ruby # CocoaPods, from the branch pod 'RevenueDotPurchases', :git => 'https://github.com/revenuedot/purchases-ios.git', :branch => 'revenuedot/main-patches' ``` The fork's default host is RevenueDot Cloud, so a Cloud project needs no `Purchases.proxyURL`. When you self-host, keep setting it to your server. ## Trusted Entitlements - **Stock SDK:** it checks signatures with RevenueCat's key, so RevenueDot responses read as failed. The default mode, `.informational`, logs the failure and still grants access. Set `.disabled` to stop the noise. **Never use `.enforced` with the stock SDK**: every request would fail. - **Fork:** it trusts RevenueDot Cloud's key. A self-hosted server signs with its own key (`REVENUEDOT_SIGNING_KEY`), so keep `.disabled` or `.informational`, or build the fork with your own public key. Details, key generation and self-host builds: [Trusted Entitlements](https://revenuedot.app/docs/guides/trusted-entitlements.md). ## Check an entitlement and make a purchase The API is RevenueCat's, unchanged. ```swift let customerInfo = try await Purchases.shared.customerInfo() let isPro = customerInfo.entitlements["pro"]?.isActive == true let offerings = try await Purchases.shared.offerings() if let package = offerings.current?.availablePackages.first { let result = try await Purchases.shared.purchase(package: package) if !result.userCancelled, result.customerInfo.entitlements["pro"]?.isActive == true { // Unlock pro features. } } ``` The purchase goes to `POST /v1/receipts`. RevenueDot verifies it with Apple, which needs the App Store in-app purchase key on the app. See [Connect the App Store](https://revenuedot.app/docs/guides/app-store.md). ## Test Store Create a `test_store` app in RevenueDot and pass its `test_...` key to `configure`. The SDK then shows a Test Store alert instead of the App Store sheet. - **Test Store keys only work in Debug builds.** In a Release build the SDK shows a "Wrong API Key" alert and stops the app on purpose. - **Known issue on older servers:** the native iOS SDK could not load Test Store products from RevenueDot and reported "No base price found for product". The server sent `cycle_count: null` in `GET /rcbilling/v1/subscribers/{id}/products`. The server's `main` branch fixed this on 2026-09-30. If you see the error, update your server. See [Known issues](https://revenuedot.app/docs/help/known-issues.md). - Test Store prices come from each product's Test Store price. Set it in the dashboard (Product catalog, Edit product) or with `test_store_price` on `POST /v2/projects/{project_id}/products`; a product without one shows 0. - To test with Apple's sandbox or Xcode's StoreKit testing instead, see [Sandbox testing](https://revenuedot.app/docs/guides/sandbox-testing.md). More: [Test Store](https://revenuedot.app/docs/guides/test-store.md). ## Migrate from RevenueCat ```diff import RevenueCat +// Point the SDK at your RevenueDot server; nothing else in the app changes. Set it before configure. +Purchases.proxyURL = URL(string: "https://revenuedot.example.com")! -Purchases.configure(withAPIKey: "appl_...") +Purchases.configure( + with: Configuration.Builder(withAPIKey: "appl_...") + // The default (informational) logs every RevenueDot response as failed signature verification. + .with(entitlementVerificationMode: .disabled) + .build() +) +// Once, after this update: send purchases made while the app talked to RevenueCat. +_ = try? await Purchases.shared.syncPurchases() ``` The full order of steps is in [Migrate from RevenueCat](https://revenuedot.app/docs/migrate.md). ## Examples - [mobile/ios-swiftui](https://github.com/revenuedot/examples/tree/main/mobile/ios-swiftui): a SwiftUI paywall app. It builds for the iOS simulator; its README says exactly what was run. ## Related - [All SDKs](https://revenuedot.app/docs/sdks.md) - [Connect the App Store](https://revenuedot.app/docs/guides/app-store.md) - [Trusted Entitlements](https://revenuedot.app/docs/guides/trusted-entitlements.md) - [Restore purchases](https://revenuedot.app/docs/help/restore-purchases.md) - [Receipt errors: 4xx vs 5xx](https://revenuedot.app/docs/help/receipt-errors-4xx-vs-5xx.md) --- # How do I use RevenueDot with the Android SDK? Source: https://revenuedot.app/docs/sdks/android.md Description: Set Purchases.proxyURL before configure and set EntitlementVerificationMode.DISABLED. The stock SDK still sends diagnostics, paywall and ad events to RevenueCat; the fork fixes that. Set `Purchases.proxyURL` to your RevenueDot server before `Purchases.configure`, and set `EntitlementVerificationMode.DISABLED`. Purchases, customer info and offerings then go to RevenueDot. **The stock Android SDK still sends diagnostics, paywall events and ad events to RevenueCat's hosts**, even with a proxy URL; the RevenueDot fork sends them to your server. ## Use the RevenueCat SDK you already ship (proxy mode) ```kotlin import com.revenuecat.purchases.EntitlementVerificationMode import com.revenuecat.purchases.Purchases import com.revenuecat.purchases.PurchasesConfiguration import java.net.URL class MainApplication : Application() { override fun onCreate() { super.onCreate() // Point the SDK at your RevenueDot server; nothing else in the app changes. Purchases.proxyURL = URL("https://revenuedot.example.com") Purchases.configure( PurchasesConfiguration.Builder(this, "goog_...") // The default (INFORMATIONAL) logs every RevenueDot response as a failed signature check. .entitlementVerificationMode(EntitlementVerificationMode.DISABLED) .build() ) } } ``` - Set `proxyURL` **before** `configure`. - Use the app's `goog_...` key from RevenueDot, or your old RevenueCat key if the [importer](https://revenuedot.app/docs/migrate/importer.md) kept it. See [Which key goes where](https://revenuedot.app/docs/concepts/projects-and-apps.md#which-key-goes-where). - The Android emulator reaches your computer at `http://10.0.2.2:8787`. Plain `http` also needs a network security config that allows cleartext traffic to that host. **After you migrate from RevenueCat, call `syncPurchases()` once** on the first launch of the update. It sends the device's Google Play purchases, with their purchase tokens, to RevenueDot. That is also how RevenueDot gets any purchase token the importer could not find. ```kotlin // Once, after this update: send purchases made while the app talked to RevenueCat. Purchases.sharedInstance.syncPurchases() ``` ## Use the RevenueDot fork The fork is [github.com/revenuedot/purchases-android](https://github.com/revenuedot/purchases-android). Kotlin packages stay `com.revenuecat.purchases.*`, so imports do not change. On top of the new default host and signing key, it makes **diagnostics, paywall events and ad events follow `proxyURL`**. **It is not published yet (2026-09-30).** The planned Maven coordinates keep the upstream artifact ids under a new group: ```kotlin // build.gradle.kts (planned; not on Maven Central yet) implementation("app.revenuedot.purchases:purchases:") implementation("app.revenuedot.purchases:purchases-ui:") // only if you use RevenueCat UI ``` Versions match upstream. The patch branch `revenuedot/main-patches` is at `10.24.0-SNAPSHOT`. **To try it today**, build it into your local Maven repository. We have not tested this path ourselves. ```bash git clone -b revenuedot/main-patches https://github.com/revenuedot/purchases-android cd purchases-android ./gradlew :purchases:publishToMavenLocal ``` Then add `mavenLocal()` to your repositories and depend on `app.revenuedot.purchases:purchases:10.24.0-SNAPSHOT`. The fork's default host is RevenueDot Cloud, so a Cloud project needs no `Purchases.proxyURL`. When you self-host, keep setting it to your server. ## Trusted Entitlements - **Stock SDK:** it checks signatures with RevenueCat's key, so RevenueDot responses read as failed. The default, `INFORMATIONAL`, logs the failure and still grants access. Set `DISABLED`. **Never use `ENFORCED` with the stock SDK**: every request would fail. - **Fork:** it trusts RevenueDot Cloud's key. A self-hosted server signs with its own key, so keep `DISABLED` or `INFORMATIONAL`, or build the fork with your own public key. See [Trusted Entitlements](https://revenuedot.app/docs/guides/trusted-entitlements.md). ## Check an entitlement and make a purchase The API is RevenueCat's, unchanged. These are the coroutine helpers. ```kotlin val customerInfo = Purchases.sharedInstance.awaitCustomerInfo() val isPro = customerInfo.entitlements["pro"]?.isActive == true val offerings = Purchases.sharedInstance.awaitOfferings() val pkg = offerings.current?.availablePackages?.firstOrNull() ?: return try { val result = Purchases.sharedInstance.awaitPurchase(PurchaseParams.Builder(activity, pkg).build()) val nowPro = result.customerInfo.entitlements["pro"]?.isActive == true } catch (e: PurchasesTransactionException) { if (e.userCancelled) return } ``` The purchase goes to `POST /v1/receipts` with the Google Play purchase token. RevenueDot verifies it with Google, which needs the app's service account. See [Connect Google Play](https://revenuedot.app/docs/guides/google-play.md). ## Test Store Create a `test_store` app in RevenueDot and pass its `test_...` key to `configure`. The SDK shows a Test Store dialog instead of Google Play's purchase sheet. - **Test Store keys only work in debug builds.** In a release build the SDK shows an error screen and stops the app on purpose. Ship with the `goog_` key. - Test Store prices come from each product's Test Store price. Set it in the dashboard (Product catalog, Edit product) or with `test_store_price` on `POST /v2/projects/{project_id}/products`; a product without one shows 0. - For Google Play test tracks and license testers, see [Sandbox testing](https://revenuedot.app/docs/guides/sandbox-testing.md). More: [Test Store](https://revenuedot.app/docs/guides/test-store.md). ## Migrate from RevenueCat ```diff override fun onCreate() { super.onCreate() + // Point the SDK at your RevenueDot server; nothing else in the app changes. Must be set before configure. + Purchases.proxyURL = URL("https://revenuedot.example.com") Purchases.configure( PurchasesConfiguration.Builder(this, "goog_...") + // The default (INFORMATIONAL) logs every RevenueDot response as failed signature verification. + .entitlementVerificationMode(EntitlementVerificationMode.DISABLED) .build() ) + // Once, after this update: send purchases made while the app talked to RevenueCat. + Purchases.sharedInstance.syncPurchases() } ``` The full order of steps is in [Migrate from RevenueCat](https://revenuedot.app/docs/migrate.md). ## Examples - [mobile/android-compose](https://github.com/revenuedot/examples/tree/main/mobile/android-compose): a Jetpack Compose paywall app. It is written but has not been compiled yet. ## Related - [All SDKs](https://revenuedot.app/docs/sdks.md) - [Connect Google Play](https://revenuedot.app/docs/guides/google-play.md) - [Trusted Entitlements](https://revenuedot.app/docs/guides/trusted-entitlements.md) - [What differs from RevenueCat](https://revenuedot.app/docs/migrate/what-differs.md) --- # How do I use RevenueDot with React Native and Expo? Source: https://revenuedot.app/docs/sdks/react-native.md Description: Await Purchases.setProxyURL before configure. Verification is already off by default in react-native-purchases, and Expo Go and web work with a Test Store key. Call `await Purchases.setProxyURL("https://revenuedot.example.com")` before `Purchases.configure`. That is the whole change: `react-native-purchases` already defaults to `ENTITLEMENT_VERIFICATION_MODE.DISABLED`. It works on iOS and Android builds, and in Expo Go and on the web with a Test Store (`test_`) key. ## Use the RevenueCat SDK you already ship (proxy mode) ```ts import { Platform } from "react-native"; import Purchases from "react-native-purchases"; // Point the SDK at your RevenueDot server; nothing else in the app changes. await Purchases.setProxyURL("https://revenuedot.example.com"); Purchases.configure({ apiKey: Platform.OS === "ios" ? "appl_..." : "goog_...", // Leave entitlementVerificationMode unset: DISABLED is the React Native default. }); ``` - `setProxyURL` returns a promise. Await it before `configure`. - If your code sets `entitlementVerificationMode: ENTITLEMENT_VERIFICATION_MODE.INFORMATIONAL` or `ENFORCED`, remove it. See [Trusted Entitlements](https://revenuedot.app/docs/sdks/react-native.md#trusted-entitlements-are-off-by-default). - Use each app's public key from RevenueDot, or your old RevenueCat keys if the [importer](https://revenuedot.app/docs/migrate/importer.md) kept them. See [Which key goes where](https://revenuedot.app/docs/concepts/projects-and-apps.md#which-key-goes-where). **After you migrate from RevenueCat, sync once** on the first launch of the update. It sends the device's store purchases to RevenueDot, so current subscribers keep access. ```ts // Once, after this update: send purchases made while the app talked to RevenueCat. await Purchases.syncPurchasesForResult(); ``` `Purchases.syncPurchases()` does the same and returns nothing. ## Use the RevenueDot fork The fork is [github.com/revenuedot/react-native-purchases](https://github.com/revenuedot/react-native-purchases). It depends on RevenueDot's [hybrid common](https://revenuedot.app/docs/sdks/hybrid-common.md) builds, which trust RevenueDot's signing key. **It is not published yet (2026-09-30).** The planned install uses npm aliases, so every `import ... from "react-native-purchases"` stays as it is: ```json { "dependencies": { "react-native-purchases": "npm:@revenuedot/react-native-purchases@", "react-native-purchases-ui": "npm:@revenuedot/react-native-purchases-ui@" } } ``` The patch branch `revenuedot/main-patches` is at version 10.10.2. Installing it from git does not work yet: it needs `@revenuedot/purchases-typescript-internal`, the `RevenueDotPurchasesHybridCommon` pod and the `app.revenuedot.purchases:purchases-hybrid-common` Maven artifact, and none of them is published. Use proxy mode until then. ## Trusted Entitlements are off by default - **Stock SDK:** the default is `DISABLED`, which is what you want against RevenueDot. `INFORMATIONAL` logs every response as a failed signature check. **`ENFORCED` would fail every request.** - **Fork:** it trusts RevenueDot Cloud's key. A self-hosted server signs with its own key, so keep `DISABLED`, or build the forks with your own public key. - In Expo Go and on the web, the SDK runs in browser mode and does not check signatures. See [Trusted Entitlements](https://revenuedot.app/docs/guides/trusted-entitlements.md). ## Check an entitlement and make a purchase ```ts const customerInfo = await Purchases.getCustomerInfo(); const isPro = customerInfo.entitlements.active["pro"] !== undefined; const offerings = await Purchases.getOfferings(); const pkg = offerings.current?.availablePackages[0]; if (pkg) { try { const { customerInfo: after } = await Purchases.purchasePackage(pkg); const nowPro = after.entitlements.active["pro"] !== undefined; } catch (e: any) { if (!e.userCancelled) throw e; } } ``` ## Test Store Create a `test_store` app in RevenueDot and use its `test_...` key. - **Expo Go and web:** `react-native-purchases` runs in browser mode there and only accepts `test_` and `rcb_` keys. RevenueDot accepts only `test_` of those two today. Tap a package, then **Test valid purchase** in the dialog. - **Native iOS builds:** see the iOS Test Store note in [iOS](https://revenuedot.app/docs/sdks/ios.md#test-store). Servers older than the 2026-09-30 fix could not serve Test Store products to the native iOS SDK. - **Native builds** accept `test_` keys only in debug builds. Ship with the `appl_` and `goog_` keys. - Test Store prices come from each product's Test Store price. Set it in the dashboard (Product catalog, Edit product) or with `test_store_price` on `POST /v2/projects/{project_id}/products`; a product without one shows 0. More: [Test Store](https://revenuedot.app/docs/guides/test-store.md). ## Migrate from RevenueCat ```diff import Purchases from "react-native-purchases"; +// Point the SDK at your RevenueDot server; nothing else in the app changes. Await it before configure. +await Purchases.setProxyURL("https://revenuedot.example.com"); Purchases.configure({ apiKey: Platform.OS === "ios" ? "appl_..." : "goog_...", - entitlementVerificationMode: ENTITLEMENT_VERIFICATION_MODE.INFORMATIONAL, + // DISABLED is the React Native default; RevenueDot does not sign responses with RevenueCat's key. }); +// Once, after this update: send purchases made while the app talked to RevenueCat. +await Purchases.syncPurchasesForResult(); ``` The full order of steps is in [Migrate from RevenueCat](https://revenuedot.app/docs/migrate.md). ## Examples - [mobile/react-native-expo](https://github.com/revenuedot/examples/tree/main/mobile/react-native-expo): an Expo app that loads offerings, buys through the Test Store and shows the entitlement. It was run on the web against RevenueDot; native builds are not verified yet. ## Related - [All SDKs](https://revenuedot.app/docs/sdks.md) - [Hybrid common](https://revenuedot.app/docs/sdks/hybrid-common.md) - [Test Store](https://revenuedot.app/docs/guides/test-store.md) - [Trusted Entitlements](https://revenuedot.app/docs/guides/trusted-entitlements.md) --- # How do I use RevenueDot with Flutter? Source: https://revenuedot.app/docs/sdks/flutter.md Description: Await Purchases.setProxyURL before configure on iOS and Android. Flutter web ignores the proxy URL in the stock purchases_flutter package; the RevenueDot fork fixes it. Call `await Purchases.setProxyURL('https://revenuedot.example.com')` before `Purchases.configure`. Verification is already `disabled` by default in `purchases_flutter`, so there is nothing else to change on iOS and Android. **Flutter web does not work in proxy mode with the stock package**: its web plugin ignores `setProxyURL`, so web calls still go to RevenueCat. The RevenueDot fork fixes this. ## Use the RevenueCat SDK you already ship (proxy mode) ```dart import 'dart:io' show Platform; import 'package:purchases_flutter/purchases_flutter.dart'; Future initPurchases() async { // Point the SDK at your RevenueDot server; nothing else in the app changes. await Purchases.setProxyURL('https://revenuedot.example.com'); await Purchases.configure( PurchasesConfiguration(Platform.isIOS ? 'appl_...' : 'goog_...'), // entitlementVerificationMode stays at its default, EntitlementVerificationMode.disabled. ); } ``` - Await `setProxyURL` before `configure`. - Use each app's public key from RevenueDot, or your old RevenueCat keys if the [importer](https://revenuedot.app/docs/migrate/importer.md) kept them. See [Which key goes where](https://revenuedot.app/docs/concepts/projects-and-apps.md#which-key-goes-where). **After you migrate from RevenueCat, sync once** on the first launch of the update, so current subscribers keep access: ```dart // Once, after this update: send purchases made while the app talked to RevenueCat. await Purchases.syncPurchases(); ``` ## Use the RevenueDot fork The fork is [github.com/revenuedot/purchases-flutter](https://github.com/revenuedot/purchases-flutter). The package names stay `purchases_flutter` and `purchases_ui_flutter`, so every `import 'package:purchases_flutter/purchases_flutter.dart'` keeps working. It also **makes `setProxyURL` work on Flutter web**. **It is not published yet (2026-09-30).** It will ship as a git dependency with release tags `-revenuedot`, because pub.dev names belong to RevenueCat: ```yaml # pubspec.yaml (planned) dependencies: purchases_flutter: git: url: https://github.com/revenuedot/purchases-flutter.git ref: -revenuedot ``` The patch branch `revenuedot/main-patches` is at version 10.13.2. Pointing `ref` at that branch resolves the Dart package, but iOS and Android builds fail today: the native side needs the `RevenueDotPurchasesHybridCommon` pod and the `app.revenuedot.purchases:purchases-hybrid-common` Maven artifact, which are not published. See [Hybrid common](https://revenuedot.app/docs/sdks/hybrid-common.md). ## Trusted Entitlements are off by default - **Stock package:** `entitlementVerificationMode` defaults to `EntitlementVerificationMode.disabled`, which is right for RevenueDot. `informational` logs every response as a failed signature check, and **`enforced` would fail every request**. - **Fork:** it trusts RevenueDot Cloud's key. A self-hosted server signs with its own key, so keep `disabled`, or build the forks with your own public key. See [Trusted Entitlements](https://revenuedot.app/docs/guides/trusted-entitlements.md). ## Check an entitlement and make a purchase ```dart import 'package:flutter/services.dart' show PlatformException; final customerInfo = await Purchases.getCustomerInfo(); final isPro = customerInfo.entitlements.active.containsKey('pro'); final offerings = await Purchases.getOfferings(); final package = offerings.current?.availablePackages.first; if (package != null) { try { final result = await Purchases.purchase(PurchaseParams.package(package)); final nowPro = result.customerInfo.entitlements.active.containsKey('pro'); } on PlatformException catch (e) { if (PurchasesErrorHelper.getErrorCode(e) != PurchasesErrorCode.purchaseCancelledError) rethrow; } } ``` `Purchases.purchasePackage` still works but is deprecated in 10.x. ## Test Store Create a `test_store` app in RevenueDot and use its `test_...` key. The Test Store dialog replaces the store sheet; tap **Test valid purchase**. - **Native builds** accept `test_` keys only in debug builds. Ship with the `appl_` and `goog_` keys. - **iOS:** servers older than the 2026-09-30 fix could not serve Test Store products to the native iOS SDK. See [iOS](https://revenuedot.app/docs/sdks/ios.md#test-store). - **Flutter web** needs the fork, because the stock web plugin ignores the proxy URL. - Test Store prices come from each product's Test Store price. Set it in the dashboard (Product catalog, Edit product) or with `test_store_price` on `POST /v2/projects/{project_id}/products`; a product without one shows 0. More: [Test Store](https://revenuedot.app/docs/guides/test-store.md). ## Migrate from RevenueCat ```diff import 'package:purchases_flutter/purchases_flutter.dart'; Future initPurchases() async { + // Point the SDK at your RevenueDot server; nothing else in the app changes. Await it before configure. + await Purchases.setProxyURL('https://revenuedot.example.com'); await Purchases.configure(PurchasesConfiguration(Platform.isIOS ? 'appl_...' : 'goog_...')); + // Once, after this update: send purchases made while the app talked to RevenueCat. + await Purchases.syncPurchases(); } ``` The full order of steps is in [Migrate from RevenueCat](https://revenuedot.app/docs/migrate.md). ## Examples - [mobile/flutter](https://github.com/revenuedot/examples/tree/main/mobile/flutter): a Flutter paywall app. It is written but has not been analyzed or run on a device yet. ## Related - [All SDKs](https://revenuedot.app/docs/sdks.md) - [Hybrid common](https://revenuedot.app/docs/sdks/hybrid-common.md) - [Test Store](https://revenuedot.app/docs/guides/test-store.md) - [What differs from RevenueCat](https://revenuedot.app/docs/migrate/what-differs.md) --- # How do I use RevenueDot with the web SDK (purchases-js)? Source: https://revenuedot.app/docs/sdks/web.md Description: Pass httpConfig.proxyURL to Purchases.configure and turn off analytics events. Only Test Store (test_) keys work against RevenueDot today; Web Billing, Stripe and Paddle do not. Pass `httpConfig: { proxyURL: "https://revenuedot.example.com" }` to `Purchases.configure`, and set `flags: { collectAnalyticsEvents: false }` so the stock SDK does not send analytics events to RevenueCat. **Only Test Store (`test_`) keys work against RevenueDot today.** Web Billing (`rcb_`), Stripe (`strp_`) and Paddle (`pdl_`) purchases do not: RevenueDot answers their receipts with error 7662. ## Use the RevenueCat SDK you already ship (proxy mode) ```ts import { Purchases } from "@revenuecat/purchases-js"; const purchases = Purchases.configure({ apiKey: "test_...", appUserId: "user_123", // Point the SDK at your RevenueDot server; nothing else in the app changes. No trailing slash. httpConfig: { proxyURL: "https://revenuedot.example.com" }, // The stock SDK sends analytics events to RevenueCat even with a proxy URL; turn them off. flags: { collectAnalyticsEvents: false }, }); ``` - The SDK rejects a proxy URL that ends with `/`. - purchases-js needs an `appUserId`. Pass your signed-in user's id, or one from `Purchases.generateRevenueCatAnonymousAppUserId()`. See [Anonymous app user IDs](https://revenuedot.app/docs/concepts/customers-and-app-user-ids.md#anonymous-app-user-ids). - Your RevenueDot server allows browser calls to `/v1` and `/rcbilling` from any origin. - There is no store history to sync on the web, so no `syncPurchases` step is needed after a migration. ## Use the RevenueDot fork The fork is [github.com/revenuedot/purchases-js](https://github.com/revenuedot/purchases-js). It sends **analytics events to `httpConfig.proxyURL`** too, and the checkout reads "Secure checkout by RevenueDot". Its default host is `https://api.revenuedot.app`, RevenueDot Cloud, so a Cloud project needs no proxy URL with the fork. Self-hosters keep setting `proxyURL` to their own server. **It is not published yet (2026-09-30).** The planned install keeps your imports through an npm alias: ```json { "dependencies": { "@revenuecat/purchases-js": "npm:@revenuedot/purchases-js@" } } ``` **To use it today**, build a tarball from the patch branch (version 1.67.0). It needs Node and pnpm: ```bash git clone -b revenuedot/main-patches https://github.com/revenuedot/purchases-js cd purchases-js pnpm install && pnpm build && pnpm pack # In your app: npm install "@revenuecat/purchases-js@file:../purchases-js/revenuedot-purchases-js-1.67.0.tgz" ``` With the fork you can leave `collectAnalyticsEvents` on. The fork's web build ran end to end against a real RevenueDot server: configure, customer info, offerings, a Test Store purchase, and the `pro` entitlement turning active, with every request going to the proxy URL. ## Trusted Entitlements do not apply purchases-js does not check response signatures, so there is nothing to turn off. The server still signs `/v1` and `/rcbilling` responses when `REVENUEDOT_SIGNING_KEY` is set, for the native SDKs. See [Trusted Entitlements](https://revenuedot.app/docs/guides/trusted-entitlements.md). ## Check an entitlement and make a purchase ```ts const customerInfo = await purchases.getCustomerInfo(); const isPro = "pro" in customerInfo.entitlements.active; const offerings = await purchases.getOfferings(); const rcPackage = offerings.current?.availablePackages[0]; if (rcPackage) { const { customerInfo: after } = await purchases.purchase({ rcPackage }); const nowPro = "pro" in after.entitlements.active; } ``` With a `test_` key, `purchase` opens the Test Store modal instead of a payment form. ## Test Store is the only web store today - Create a `test_store` app in RevenueDot and use its `test_...` key. See [Test Store](https://revenuedot.app/docs/guides/test-store.md). - Test Store purchases are always sandbox purchases. - Test Store prices come from each product's Test Store price. Set it in the dashboard (Product catalog, Edit product) or with `test_store_price` on `POST /v2/projects/{project_id}/products`; a product without one shows 0. - `rcb_`, `strp_` and `pdl_` apps can be created, but RevenueDot does not accept their purchases yet. Web Billing is planned for a later tier; see [What differs from RevenueCat](https://revenuedot.app/docs/migrate/what-differs.md). ## Migrate from RevenueCat ```diff const purchases = Purchases.configure({ apiKey: "test_...", appUserId, + // Point the SDK at your RevenueDot server; nothing else in the app changes. No trailing slash. + httpConfig: { proxyURL: "https://revenuedot.example.com" }, + // Analytics events do not use the proxy URL; turn them off to keep all traffic on your server. + flags: { collectAnalyticsEvents: false }, }); ``` If you sell through RevenueCat Web Billing today, keep those subscriptions on RevenueCat: the importer copies their current access, but renewals stay with RevenueCat. See [Migrate from RevenueCat](https://revenuedot.app/docs/migrate.md). ## Examples - [web/purchases-js-vite](https://github.com/revenuedot/examples/tree/main/web/purchases-js-vite): a Vite page that configures purchases-js with a proxy URL, buys through the Test Store and shows the entitlement. ## Related - [All SDKs](https://revenuedot.app/docs/sdks.md) - [Test Store](https://revenuedot.app/docs/guides/test-store.md) - [Customers and app user IDs](https://revenuedot.app/docs/concepts/customers-and-app-user-ids.md) - [What differs from RevenueCat](https://revenuedot.app/docs/migrate/what-differs.md) --- # How do I use RevenueDot with Capacitor and Ionic? Source: https://revenuedot.app/docs/sdks/capacitor.md Description: Await Purchases.setProxyURL({ url }) before configure, and pass entitlementVerificationMode DISABLED, because the Capacitor plugin passes no default and the native informational default applies. Call `await Purchases.setProxyURL({ url: "https://revenuedot.example.com" })` before `Purchases.configure`, and pass `entitlementVerificationMode: ENTITLEMENT_VERIFICATION_MODE.DISABLED`. The Capacitor plugin passes no default of its own, so without it the native iOS and Android default (informational) applies and logs every RevenueDot response as a failed signature check. ## Use the RevenueCat SDK you already ship (proxy mode) ```ts import { Capacitor } from "@capacitor/core"; import { ENTITLEMENT_VERIFICATION_MODE, Purchases } from "@revenuecat/purchases-capacitor"; // Point the SDK at your RevenueDot server; nothing else in the app changes. await Purchases.setProxyURL({ url: "https://revenuedot.example.com" }); await Purchases.configure({ apiKey: Capacitor.getPlatform() === "ios" ? "appl_..." : "goog_...", // Capacitor passes no default, so the native default (informational signature checks) would apply. entitlementVerificationMode: ENTITLEMENT_VERIFICATION_MODE.DISABLED, }); ``` - `setProxyURL` takes an object `{ url }`, not a string. Await it before `configure`. - Use each app's public key from RevenueDot, or your old RevenueCat keys if the [importer](https://revenuedot.app/docs/migrate/importer.md) kept them. See [Which key goes where](https://revenuedot.app/docs/concepts/projects-and-apps.md#which-key-goes-where). **After you migrate from RevenueCat, sync once** on the first launch of the update, so current subscribers keep access: ```ts // Once, after this update: send purchases made while the app talked to RevenueCat. await Purchases.syncPurchases(); ``` ## Use the RevenueDot fork The fork is [github.com/revenuedot/purchases-capacitor](https://github.com/revenuedot/purchases-capacitor). It depends on RevenueDot's [hybrid common](https://revenuedot.app/docs/sdks/hybrid-common.md) builds, which trust RevenueDot's signing key. **It is not published yet (2026-09-30).** Install it **only through the alias** below. Capacitor derives the native pod and Swift package names from the npm package name, so the alias keeps them as `RevenuecatPurchasesCapacitor`. A direct install of `@revenuedot/purchases-capacitor` would change those names and is not supported. ```json { "dependencies": { "@revenuecat/purchases-capacitor": "npm:@revenuedot/purchases-capacitor@", "@revenuecat/purchases-capacitor-ui": "npm:@revenuedot/purchases-capacitor-ui@" } } ``` Then run `npx cap sync`. The patch branch `revenuedot/main-patches` is at version 13.6.1. It cannot be installed from git yet, because its native dependencies (`RevenueDotPurchasesHybridCommon` 19.4.1 and `app.revenuedot.purchases:purchases-hybrid-common:19.4.1`) are not published. Use proxy mode until then. ## Trusted Entitlements - **Stock plugin:** pass `DISABLED`. The native default, `INFORMATIONAL`, logs every RevenueDot response as a failed check but still grants access. **`ENFORCED` would fail every request.** - **Fork:** it trusts RevenueDot Cloud's key. A self-hosted server signs with its own key, so keep `DISABLED`, or build the forks with your own public key. See [Trusted Entitlements](https://revenuedot.app/docs/guides/trusted-entitlements.md). ## Check an entitlement and make a purchase ```ts const { customerInfo } = await Purchases.getCustomerInfo(); const isPro = customerInfo.entitlements.active["pro"] !== undefined; const offerings = await Purchases.getOfferings(); const aPackage = offerings.current?.availablePackages[0]; if (aPackage) { const result = await Purchases.purchasePackage({ aPackage }); const nowPro = result.customerInfo.entitlements.active["pro"] !== undefined; } ``` `getCustomerInfo` resolves to `{ customerInfo }`, and `purchasePackage` takes `{ aPackage }`. ## Test Store Create a `test_store` app in RevenueDot and use its `test_...` key on a debug build. The Test Store dialog replaces the store sheet. - Native builds accept `test_` keys only in debug builds. Ship with the `appl_` and `goog_` keys. - iOS: servers older than the 2026-09-30 fix could not serve Test Store products to the native iOS SDK. See [iOS](https://revenuedot.app/docs/sdks/ios.md#test-store). - Test Store prices come from each product's Test Store price. Set it in the dashboard (Product catalog, Edit product) or with `test_store_price` on `POST /v2/projects/{project_id}/products`; a product without one shows 0. More: [Test Store](https://revenuedot.app/docs/guides/test-store.md). ## Migrate from RevenueCat ```diff -import { Purchases } from "@revenuecat/purchases-capacitor"; +import { ENTITLEMENT_VERIFICATION_MODE, Purchases } from "@revenuecat/purchases-capacitor"; +// Point the SDK at your RevenueDot server; nothing else in the app changes. Await it before configure. +await Purchases.setProxyURL({ url: "https://revenuedot.example.com" }); await Purchases.configure({ apiKey: isIOS ? "appl_..." : "goog_...", + // Capacitor passes no default, so the native default (informational signature checks) would apply. + entitlementVerificationMode: ENTITLEMENT_VERIFICATION_MODE.DISABLED, }); +// Once, after this update: send purchases made while the app talked to RevenueCat. +await Purchases.syncPurchases(); ``` The full order of steps is in [Migrate from RevenueCat](https://revenuedot.app/docs/migrate.md). ## Examples There is no Capacitor example yet. The [React Native Expo example](https://github.com/revenuedot/examples/tree/main/mobile/react-native-expo) shows the same calls in a JavaScript app. ## Related - [All SDKs](https://revenuedot.app/docs/sdks.md) - [Hybrid common](https://revenuedot.app/docs/sdks/hybrid-common.md) - [Trusted Entitlements](https://revenuedot.app/docs/guides/trusted-entitlements.md) - [Test Store](https://revenuedot.app/docs/guides/test-store.md) --- # How do I use RevenueDot with Kotlin Multiplatform? Source: https://revenuedot.app/docs/sdks/kotlin-multiplatform.md Description: Set Purchases.proxyURL, a String, before Purchases.configure in common code. purchases-kmp already defaults to EntitlementVerificationMode.DISABLED, so nothing else changes. Set `Purchases.proxyURL = "https://revenuedot.example.com"` in common code before `Purchases.configure`. In `purchases-kmp` the proxy URL is a `String`, not a `URL`. The default verification mode is already `EntitlementVerificationMode.DISABLED`, so nothing else changes on Android or iOS. ## Use the RevenueCat SDK you already ship (proxy mode) ```kotlin import com.revenuecat.purchases.kmp.Purchases import com.revenuecat.purchases.kmp.PurchasesConfiguration import com.revenuecat.purchases.kmp.models.EntitlementVerificationMode fun initPurchases(apiKey: String) { // Point the SDK at your RevenueDot server; nothing else in the app changes. Purchases.proxyURL = "https://revenuedot.example.com" Purchases.configure(PurchasesConfiguration(apiKey) { // DISABLED is the KMP default; keep it, RevenueDot does not sign responses with RevenueCat's key. verificationMode = EntitlementVerificationMode.DISABLED }) } ``` - Set `proxyURL` before `configure`. It applies to both the Android and the iOS targets. - Pass the `appl_...` key on iOS and the `goog_...` key on Android, from RevenueDot or kept by the [importer](https://revenuedot.app/docs/migrate/importer.md). See [Which key goes where](https://revenuedot.app/docs/concepts/projects-and-apps.md#which-key-goes-where). - On Android, the stock native SDK underneath still sends diagnostics, paywall events and ad events to RevenueCat. See [Android](https://revenuedot.app/docs/sdks/android.md). **After you migrate from RevenueCat, sync once** on the first launch of the update, so current subscribers keep access: ```kotlin // Once, after this update: send purchases made while the app talked to RevenueCat. Purchases.sharedInstance.awaitSyncPurchases() ``` `awaitSyncPurchases` is a suspend function in `com.revenuecat.purchases.kmp.ktx`. The callback form is `syncPurchases(onError = { }, onSuccess = { })`. ## Use the RevenueDot fork The fork is [github.com/revenuedot/purchases-kmp](https://github.com/revenuedot/purchases-kmp). Kotlin packages stay `com.revenuecat.purchases.kmp.*`. It builds its iOS side from RevenueDot's purchases-ios fork (a git submodule at 5.91.0) and its Android side from RevenueDot's Android fork, so both trust RevenueDot's signing key and send events to your proxy URL. **It is not published yet (2026-09-30).** The planned Maven coordinates: ```kotlin // build.gradle.kts, commonMain dependencies (planned; not on Maven Central yet) implementation("app.revenuedot.purchases:purchases-kmp-core:") implementation("app.revenuedot.purchases:purchases-kmp-ui:") // paywalls, optional ``` The other published modules are `purchases-kmp-models`, `-mappings`, `-either` and `-result`; `-core` pulls in what it needs. The patch branch `revenuedot/main-patches` is at `3.11.0-SNAPSHOT`. Building it today needs the unpublished Android fork (`app.revenuedot.purchases:purchases`) in a local Maven repository first, so proxy mode is the practical choice until the release. ## Trusted Entitlements are off by default - **Stock SDK:** the default is `DISABLED`, which is right for RevenueDot. `INFORMATIONAL` logs every response as a failed check, and **`ENFORCED` would fail every request**. - **Fork:** it trusts RevenueDot Cloud's key. A self-hosted server signs with its own key, so keep `DISABLED`, or build the forks with your own public key. See [Trusted Entitlements](https://revenuedot.app/docs/guides/trusted-entitlements.md). ## Check an entitlement and make a purchase ```kotlin import com.revenuecat.purchases.kmp.ktx.awaitCustomerInfo import com.revenuecat.purchases.kmp.ktx.awaitOfferings import com.revenuecat.purchases.kmp.ktx.awaitPurchase val customerInfo = Purchases.sharedInstance.awaitCustomerInfo() val isPro = customerInfo.entitlements.active.containsKey("pro") val offerings = Purchases.sharedInstance.awaitOfferings() val pkg = offerings.current?.availablePackages?.firstOrNull() ?: return val result = Purchases.sharedInstance.awaitPurchase(pkg) val nowPro = result.customerInfo.entitlements.active.containsKey("pro") ``` `awaitPurchase` throws `PurchasesTransactionException` when the purchase fails or the user cancels; check its `userCancelled`. ## Test Store Create a `test_store` app in RevenueDot and pass its `test_...` key on a debug build. The Test Store dialog replaces the store sheet. - Native SDKs accept `test_` keys only in debug builds. - iOS: servers older than the 2026-09-30 fix could not serve Test Store products to the native iOS SDK. See [iOS](https://revenuedot.app/docs/sdks/ios.md#test-store). - Test Store prices come from each product's Test Store price. Set it in the dashboard (Product catalog, Edit product) or with `test_store_price` on `POST /v2/projects/{project_id}/products`; a product without one shows 0. More: [Test Store](https://revenuedot.app/docs/guides/test-store.md). ## Migrate from RevenueCat ```diff fun initPurchases(apiKey: String) { + // Point the SDK at your RevenueDot server; nothing else in the app changes. A String, set before configure. + Purchases.proxyURL = "https://revenuedot.example.com" Purchases.configure(PurchasesConfiguration(apiKey) { + // DISABLED is the KMP default; keep it, RevenueDot does not sign responses with RevenueCat's key. + verificationMode = EntitlementVerificationMode.DISABLED }) + // Once, after this update (from a coroutine): send purchases made while the app talked to RevenueCat. + // Purchases.sharedInstance.awaitSyncPurchases() } ``` The full order of steps is in [Migrate from RevenueCat](https://revenuedot.app/docs/migrate.md). ## Examples There is no Kotlin Multiplatform example yet. The calls match the [Android](https://revenuedot.app/docs/sdks/android.md) guide. ## Related - [All SDKs](https://revenuedot.app/docs/sdks.md) - [Android](https://revenuedot.app/docs/sdks/android.md) - [iOS](https://revenuedot.app/docs/sdks/ios.md) - [Trusted Entitlements](https://revenuedot.app/docs/guides/trusted-entitlements.md) --- # How do I use RevenueDot with Unity? Source: https://revenuedot.app/docs/sdks/unity.md Description: Fill in the Proxy URL field on the Purchases component and set Entitlement Verification Mode to Disabled in the Inspector. Unity has no public SetProxyURL method. In the Inspector, on the GameObject with the **Purchases** component, set **Proxy URL** to your RevenueDot server and **Entitlement Verification Mode** to **Disabled**. Unity has no public `SetProxyURL` method, so the field is the only way to set the proxy. The component applies it before it configures the SDK, also when you configure from code. ## Use the RevenueCat SDK you already ship (proxy mode) | Inspector field | Before | After | |---|---|---| | Proxy URL (under **Advanced**) | empty | `https://revenuedot.example.com` | | Entitlement Verification Mode | Informational | Disabled | | Revenue Cat API Key Apple / Google | your RevenueCat keys | the `appl_` and `goog_` keys from RevenueDot, or the RevenueCat keys if the [importer](https://revenuedot.app/docs/migrate/importer.md) kept them | If you configure at runtime (**Use Runtime Setup** checked), the Proxy URL field still applies: `Purchases.Start()` sets it before it checks that box. The field's tooltip says otherwise, but the code applies it. Your script must run after `Purchases.Start()`, which also creates the native wrapper; calling `Configure` before it throws a `NullReferenceException`. `[DefaultExecutionOrder(100)]` makes Unity call your `Start()` after it. ```csharp using UnityEngine; // Runs after Purchases.Start(), which creates the native wrapper and applies the Proxy URL field. [DefaultExecutionOrder(100)] [RequireComponent(typeof(Purchases))] public class Store : MonoBehaviour { void Start() { // Needs "Use Runtime Setup" checked on the Purchases component. // The proxy URL comes from the Proxy URL field on that component. var purchases = GetComponent(); purchases.Configure(Purchases.PurchasesConfiguration.Builder.Init("appl_...") // The default (Informational) logs every RevenueDot response as a failed signature check. .SetEntitlementVerificationMode(Purchases.EntitlementVerificationMode.Disabled) .Build()); } } ``` **After you migrate from RevenueCat, sync once** on the first launch of the update, so current subscribers keep access: ```csharp // Once, after this update: send purchases made while the app talked to RevenueCat. GetComponent().SyncPurchases(); ``` ## Use the RevenueDot fork The fork is [github.com/revenuedot/purchases-unity](https://github.com/revenuedot/purchases-unity). C# namespaces and assembly names stay the same, so `using RevenueCat;` keeps working. Its native dependencies are RevenueDot's [hybrid common](https://revenuedot.app/docs/sdks/hybrid-common.md) builds, pulled in by the External Dependency Manager (EDM4U). **It is not published yet (2026-09-30).** The planned install is from OpenUPM: ```bash openupm add com.revenuedot.purchases-unity openupm add com.revenuedot.purchases-ui-unity # paywalls, optional ``` The patch branch `revenuedot/main-patches` is at version 9.11.1. It cannot build a working app yet, because its native dependencies (`RevenueDotPurchasesHybridCommon` 19.4.1 and `app.revenuedot.purchases:purchases-hybrid-common:19.4.1`) are not published. Use proxy mode until then. ## Trusted Entitlements - **Stock SDK:** the Inspector offers **Disabled** and **Informational**, and the default is Informational. It logs every RevenueDot response as a failed check but still grants access. Choose **Disabled**. - **Fork:** it trusts RevenueDot Cloud's key. A self-hosted server signs with its own key, so keep Disabled, or build the forks with your own public key. See [Trusted Entitlements](https://revenuedot.app/docs/guides/trusted-entitlements.md). ## Check an entitlement and make a purchase ```csharp var purchases = GetComponent(); purchases.GetCustomerInfo((customerInfo, error) => { if (error != null) return; bool isPro = customerInfo.Entitlements.Active.ContainsKey("pro"); }); purchases.GetOfferings((offerings, error) => { if (error != null || offerings.Current == null) return; var package = offerings.Current.AvailablePackages[0]; purchases.PurchasePackage(package, result => { if (result.UserCancelled || result.Error != null) return; bool nowPro = result.CustomerInfo.Entitlements.Active.ContainsKey("pro"); }); }); ``` ## Test Store Create a `test_store` app in RevenueDot and use its `test_...` key. - Purchases only run on an iOS or Android device or simulator. In the Unity Editor the SDK uses a no-op wrapper and makes no requests. - Native SDKs accept `test_` keys only in debug builds. - iOS: servers older than the 2026-09-30 fix could not serve Test Store products to the native iOS SDK. See [iOS](https://revenuedot.app/docs/sdks/ios.md#test-store). - Test Store prices come from each product's Test Store price. Set it in the dashboard (Product catalog, Edit product) or with `test_store_price` on `POST /v2/projects/{project_id}/products`; a product without one shows 0. More: [Test Store](https://revenuedot.app/docs/guides/test-store.md). ## Migrate from RevenueCat In the Inspector: | Field | Before | After | |---|---|---| | Proxy URL | (empty) | `https://revenuedot.example.com` | | Entitlement Verification Mode | Informational | Disabled | ```diff var purchases = GetComponent(); -purchases.Configure(Purchases.PurchasesConfiguration.Builder.Init("appl_...").Build()); +purchases.Configure(Purchases.PurchasesConfiguration.Builder.Init("appl_...") + .SetEntitlementVerificationMode(Purchases.EntitlementVerificationMode.Disabled) + .Build()); +// Once, after this update: send purchases made while the app talked to RevenueCat. +purchases.SyncPurchases(); ``` The full order of steps is in [Migrate from RevenueCat](https://revenuedot.app/docs/migrate.md). ## Examples There is no Unity example yet. ## Related - [All SDKs](https://revenuedot.app/docs/sdks.md) - [Hybrid common](https://revenuedot.app/docs/sdks/hybrid-common.md) - [Trusted Entitlements](https://revenuedot.app/docs/guides/trusted-entitlements.md) - [Test Store](https://revenuedot.app/docs/guides/test-store.md) --- # How do I use RevenueDot with Cordova? Source: https://revenuedot.app/docs/sdks/cordova.md Description: Call Purchases.setProxyURL before configureWith. Cordova has no option to turn off signature checks, so it logs a verification failure for every response and still grants access. Call `Purchases.setProxyURL("https://revenuedot.example.com")` before `Purchases.configureWith`. **The Cordova plugin has no option to turn off signature checks**, so the native SDKs log "failed verification" for every RevenueDot response. Access is still granted, because the native default mode is informational. ## Use the RevenueCat SDK you already ship (proxy mode) ```js document.addEventListener("deviceready", () => { // Point the SDK at your RevenueDot server; nothing else in the app changes. Purchases.setProxyURL("https://revenuedot.example.com"); Purchases.configureWith({ apiKey: device.platform === "iOS" ? "appl_..." : "goog_...", }); }); ``` - `setProxyURL` returns nothing. Call it before `configureWith`. - `device.platform` comes from `cordova-plugin-device`. - Use each app's public key from RevenueDot, or your old RevenueCat keys if the [importer](https://revenuedot.app/docs/migrate/importer.md) kept them. See [Which key goes where](https://revenuedot.app/docs/concepts/projects-and-apps.md#which-key-goes-where). **After you migrate from RevenueCat, sync once** on the first launch of the update, so current subscribers keep access: ```js // Once, after this update: send purchases made while the app talked to RevenueCat. Purchases.syncPurchases(); ``` ## Use the RevenueDot fork The fork is [github.com/revenuedot/cordova-plugin-purchases](https://github.com/revenuedot/cordova-plugin-purchases). The plugin id stays `cordova-plugin-purchases` and the global stays `Purchases`, so `config.xml` and your code do not change. Because it trusts RevenueDot's signing key, the verification log noise goes away when your server signs with that key. **It is not published yet (2026-09-30).** The planned install: ```bash cordova plugin add @revenuedot/cordova-plugin-purchases ``` The patch branch `revenuedot/main-patches` is at version 8.2.3. It cannot be installed from git yet, because its native dependencies (`RevenueDotPurchasesHybridCommon` 19.4.1 and `app.revenuedot.purchases:purchases-hybrid-common:19.4.1`) are not published. Use proxy mode until then. ## Trusted Entitlements - **Stock plugin:** `configureWith` takes no verification mode, so the native default, informational, applies. Every RevenueDot response is logged as a failed check, and access is still granted. You cannot turn this off from JavaScript. - **Fork:** it trusts RevenueDot Cloud's key. A self-hosted server signs with its own key, so the log noise stays unless you build the forks with your own public key. See [Trusted Entitlements](https://revenuedot.app/docs/guides/trusted-entitlements.md). ## Check an entitlement and make a purchase The plugin uses callbacks. ```js Purchases.getCustomerInfo( (customerInfo) => { const isPro = customerInfo.entitlements.active["pro"] !== undefined; }, (error) => console.error(error) ); Purchases.getOfferings( (offerings) => { const aPackage = offerings.current && offerings.current.availablePackages[0]; if (!aPackage) return; Purchases.purchasePackage( aPackage, ({ customerInfo }) => { const nowPro = customerInfo.entitlements.active["pro"] !== undefined; }, ({ error, userCancelled }) => { if (!userCancelled) console.error(error); } ); }, (error) => console.error(error) ); ``` ## Test Store Create a `test_store` app in RevenueDot and use its `test_...` key on a debug build. The Test Store dialog replaces the store sheet. - Native SDKs accept `test_` keys only in debug builds. - iOS: servers older than the 2026-09-30 fix could not serve Test Store products to the native iOS SDK. See [iOS](https://revenuedot.app/docs/sdks/ios.md#test-store). - Test Store prices come from each product's Test Store price. Set it in the dashboard (Product catalog, Edit product) or with `test_store_price` on `POST /v2/projects/{project_id}/products`; a product without one shows 0. More: [Test Store](https://revenuedot.app/docs/guides/test-store.md). ## Migrate from RevenueCat ```diff document.addEventListener("deviceready", () => { + // Point the SDK at your RevenueDot server; nothing else in the app changes. Call it before configure. + Purchases.setProxyURL("https://revenuedot.example.com"); Purchases.configureWith({ apiKey: device.platform === "iOS" ? "appl_..." : "goog_..." }); + // Once, after this update: send purchases made while the app talked to RevenueCat. + Purchases.syncPurchases(); }); ``` The full order of steps is in [Migrate from RevenueCat](https://revenuedot.app/docs/migrate.md). ## Examples There is no Cordova example yet. ## Related - [All SDKs](https://revenuedot.app/docs/sdks.md) - [Hybrid common](https://revenuedot.app/docs/sdks/hybrid-common.md) - [Signature verification failed](https://revenuedot.app/docs/help/signature-verification-failed.md) - [Trusted Entitlements](https://revenuedot.app/docs/guides/trusted-entitlements.md) --- # What is purchases-hybrid-common, and do I need to install it? Source: https://revenuedot.app/docs/sdks/hybrid-common.md Description: purchases-hybrid-common is the shared native and TypeScript layer under the React Native, Flutter, Capacitor, Unity and Cordova SDKs. App developers never install it directly; the wrapper pulls it in. `purchases-hybrid-common` is the shared layer that the cross-platform SDKs (React Native, Flutter, Capacitor, Unity and Cordova) sit on. It turns their calls into calls on the native iOS and Android SDKs. **You never install it yourself**: your wrapper depends on it, and setting the proxy URL and verification mode in the wrapper is all you need. ## What it contains | Part | What it does | RevenueDot fork name (planned) | Keeps the name | |---|---|---|---| | iOS layer | Swift bridge over the iOS SDK | pods `RevenueDotPurchasesHybridCommon`, `RevenueDotPurchasesHybridCommonUI` | modules `PurchasesHybridCommon`, `PurchasesHybridCommonUI` | | Android layer | Kotlin bridge over the Android SDK | Maven `app.revenuedot.purchases:purchases-hybrid-common` (and `-ui`) | Kotlin packages | | TypeScript types | Shared types and enums such as `ENTITLEMENT_VERIFICATION_MODE` | npm `@revenuedot/purchases-typescript-internal` and `-esm` | installed through npm aliases, so imports stay `@revenuecat/...` | | Web mappings | Runs purchases-js in browser mode (Expo Go, React Native web, Flutter web) | npm `@revenuedot/purchases-js-hybrid-mappings` | installed through an npm alias | The fork is [github.com/revenuedot/purchases-hybrid-common](https://github.com/revenuedot/purchases-hybrid-common), branch `revenuedot/main-patches`, version 19.4.1. ## Which wrappers use it | Wrapper | Native layer | TypeScript or web packages | Fork pin | |---|---|---|---| | [React Native](https://revenuedot.app/docs/sdks/react-native.md) | Yes | `purchases-typescript-internal`, `purchases-js-hybrid-mappings` | 19.4.1 | | [Flutter](https://revenuedot.app/docs/sdks/flutter.md) | Yes | A copy of the web mappings for Flutter web | 19.4.1 | | [Capacitor](https://revenuedot.app/docs/sdks/capacitor.md) | Yes | `purchases-typescript-internal-esm` | 19.4.1 | | [Unity](https://revenuedot.app/docs/sdks/unity.md) | Yes, through EDM4U | none | 19.4.1 | | [Cordova](https://revenuedot.app/docs/sdks/cordova.md) | Yes | none | 19.4.1 | [Kotlin Multiplatform](https://revenuedot.app/docs/sdks/kotlin-multiplatform.md) does not use it: it builds on the iOS and Android SDKs directly. The native [iOS](https://revenuedot.app/docs/sdks/ios.md), [Android](https://revenuedot.app/docs/sdks/android.md) and [web](https://revenuedot.app/docs/sdks/web.md) SDKs do not use it either. ## How the forks fit together The RevenueDot forks pin each other, so a wrapper fork gets the RevenueDot builds all the way down: - The hybrid-common fork depends on RevenueDot's iOS fork (`RevenueDotPurchases` 5.91.0) and Android fork (`app.revenuedot.purchases:purchases` 10.23.3). - The web mappings depend on RevenueDot's purchases-js fork through an npm alias. - Every wrapper fork depends on hybrid-common 19.4.1. That chain is why the wrapper forks cannot be used before release: nothing in it is on CocoaPods, Maven Central or npm yet (2026-09-30). Publishing runs in dependency order: iOS and Android first, then hybrid-common, then purchases-js and the web mappings, then the wrappers. ## What it means for proxy mode - You do nothing here. The wrapper's `setProxyURL` and verification option pass through hybrid-common to the native SDK. - With the stock wrappers, hybrid-common is RevenueCat's build, which trusts RevenueCat's signing key. That is why you turn verification off. See [Trusted Entitlements](https://revenuedot.app/docs/guides/trusted-entitlements.md). ## Related - [All SDKs](https://revenuedot.app/docs/sdks.md) - [SDK changes when you migrate](https://revenuedot.app/docs/migrate/sdk-changes.md) - [Trusted Entitlements](https://revenuedot.app/docs/guides/trusted-entitlements.md) === Guides: stores, webhooks, self-hosting === # Which guide do I need? Source: https://revenuedot.app/docs/guides.md Description: Step-by-step guides for connecting the App Store and Google Play, receiving webhooks, response signing, testing, inviting your team, alert emails, and running RevenueDot on your own servers. Each guide is one task, start to finish. Connect a store first, then receive webhooks; run the server yourself with the self-hosting guides. | I want to | Guide | |---|---| | Accept App Store purchases and get Apple's notifications | [Connect the App Store](https://revenuedot.app/docs/guides/app-store.md) | | Accept Google Play purchases and get real-time notifications | [Connect Google Play](https://revenuedot.app/docs/guides/google-play.md) | | Receive purchase events in my backend and verify them | [Webhooks](https://revenuedot.app/docs/guides/webhooks.md) | | Have the SDK verify that responses come from my server | [Trusted Entitlements](https://revenuedot.app/docs/guides/trusted-entitlements.md) | | Test purchases without any store account | [Test Store](https://revenuedot.app/docs/guides/test-store.md) | | Test with App Store sandbox, Xcode or Google Play testers | [Sandbox testing](https://revenuedot.app/docs/guides/sandbox-testing.md) | | Run RevenueDot on my own servers | [Self-hosting](https://revenuedot.app/docs/guides/self-hosting.md) | | Upgrade my server | [Upgrades](https://revenuedot.app/docs/guides/upgrades.md) | | Back up and restore | [Backups](https://revenuedot.app/docs/guides/backups.md) | | Check everything before real customers arrive | [Going to production](https://revenuedot.app/docs/guides/going-to-production.md) | | Invite teammates to a project and set their roles | [Invite your team](https://revenuedot.app/docs/guides/team.md) | | Get an email when store notifications, webhooks or store credentials fail | [Alert emails](https://revenuedot.app/docs/guides/alerts.md) | Moving from RevenueCat? Start with [Migrate from RevenueCat](https://revenuedot.app/docs/migrate.md). Looking for a specific endpoint? See the [API reference](https://revenuedot.app/docs/api.md). --- # How do I connect the App Store to RevenueDot? Source: https://revenuedot.app/docs/guides/app-store.md Description: Create an App Store app with your bundle ID, add an In-App Purchase key (.p8, key ID, issuer ID), then set RevenueDot's notification URL as the Server Notifications v2 URL in App Store Connect. Three steps: create an App Store app in RevenueDot with your bundle ID, give it an **In-App Purchase key** so it can ask Apple about purchases, and paste its **notification URL** into App Store Connect as the Version 2 server notification URL. The dashboard's app page walks you through the same steps and checks each one. ## 1. Create the app In the dashboard: **Apps → New app → App Store**, with your bundle ID. Or with the API: ```bash curl -s -X POST "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/apps" \ -H "Authorization: Bearer $SECRET_KEY" -H "Content-Type: application/json" \ -d '{"name":"Scanner (iOS)","type":"app_store","app_store":{"bundle_id":"com.example.scanner"}}' ``` The answer holds the app's `id`. Its public SDK key (`appl_...`) is on the app's page, or at `GET /v2/projects/{project_id}/apps/{app_id}/public_api_keys`. Use type `mac_app_store` (key `mac_...`) for a separate Mac App Store app. ## 2. Add the In-App Purchase key RevenueDot uses this key to call Apple's App Store Server API: to confirm each StoreKit 2 purchase, read a customer's full history and renewal state (auto-renew, billing retry, grace period), and extend subscriptions. 1. Open [App Store Connect → Users and Access → Integrations → In-App Purchase](https://appstoreconnect.apple.com/access/integrations/api/subs). 2. Click **+**, name the key (for example RevenueDot) and click **Generate**. 3. Download the `.p8` file. Apple lets you download it only once. 4. Note the **Key ID** (10 characters) and the **Issuer ID** shown above the keys list. 5. In the dashboard, open the app → **In-app purchase key**, drop the `.p8` file, fill in the IDs and click **Check credentials**. Or with the API. RevenueDot stores these under RevenueCat's field names and never returns them: ```bash curl -s -X POST "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/apps/$APP_ID" \ -H "Authorization: Bearer $SECRET_KEY" -H "Content-Type: application/json" \ -d "$(jq -n --rawfile key AuthKey_ABC123DEFG.p8 '{app_store: {subscription_private_key: $key, subscription_key_id: "ABC123DEFG", subscription_key_issuer: "57246542-96fe-1a63-e053-0824d011072a"}}')" # Ask Apple whether the key works (one harmless API call). curl -s -X POST "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/apps/$APP_ID/actions/verify_credentials" -H "Authorization: Bearer $SECRET_KEY" ``` ```json {"object":"credentials_check","app_id":"appugfw01uy","store":"app_store","status":"valid","valid":true,"message":"Apple accepted the in-app purchase key.","checked_at":1790801342700,"key_id":"ABC123DEFG"} ``` **Without the key**, RevenueDot still verifies StoreKit 2 signed transactions against Apple's root certificate, but it knows only what the device sent: no renewal state and no history. **StoreKit 1 receipts need the key.** Without it RevenueDot answers 500 with code 7234, so the SDK keeps the purchase and retries after you add the key. (For development only, the `allow_unsigned_receipts` setting accepts StoreKit 1 receipts without checking them. Anyone could forge such a receipt, so never turn it on in production.) ## 3. Send App Store Server Notifications to RevenueDot Notifications tell RevenueDot about renewals, cancellations, billing problems and refunds when they happen, not only when the app next opens. 1. Copy the app's notification URL from the dashboard, or from the API. It looks like `https://revenuedot.example.com/v1/notifications/apple/{app_id}`: ```bash curl -s "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/apps/$APP_ID/store_settings" -H "Authorization: Bearer $SECRET_KEY" | jq -r .notification_url ``` Behind a reverse proxy, RevenueDot builds the URL from `X-Forwarded-Host` and `X-Forwarded-Proto`. Check that it shows your public HTTPS address. 2. In App Store Connect, open your app → **App Information** → **App Store Server Notifications**. 3. Paste the URL as both the **Production Server URL** and the **Sandbox Server URL**, and choose **Version 2**. 4. Make a sandbox purchase. The app's notification status turns **Ready** when the first notification about a known purchase is processed. How RevenueDot answers Apple: - **200** for every verified notification, including ones about purchases it has not seen. Those are stored, and applied only when **Track new purchases from server-to-server notifications** is on (`track_new_purchases`). - **400** when the signature is invalid, or the notification is for another bundle ID (or another Apple app ID, when you set `app_apple_id`). App Store Connect shows these as failed. - **500** when RevenueDot itself fails. Apple retries. Check the status any time: `GET /v2/projects/{project_id}/setup_health` lists each app's `notification_status` (`ready`, `failing`, `received` or `waiting`) and the last error. See [store notifications not arriving](https://revenuedot.app/docs/help/store-notifications-not-arriving.md). ## Optional settings Set these in the app's `app_store` object with `POST /v2/projects/{project_id}/apps/{app_id}`, or in the dashboard under **More settings**: | Field | What it does | |---|---| | `notification_forward_url` | Copies each notification, byte for byte, to another URL, such as RevenueCat's during a [dual run](https://revenuedot.app/docs/migrate/dual-run.md). `null` turns it off | | `track_new_purchases` | `true`: apply notifications about purchases RevenueDot has never seen. Useful during a migration | | `app_apple_id` | Your app's Apple ID (a number). Production notifications for another app ID are refused | | `xcode_certificate` | The StoreKit test certificate exported from Xcode (PEM). Lets RevenueDot accept purchases made with a StoreKit configuration file in the simulator. See [sandbox testing](https://revenuedot.app/docs/guides/sandbox-testing.md) | | `allow_unsigned_receipts` | Development only: accept StoreKit 1 receipts without the In-App Purchase key | | `app_store_connect_api_key`, `_id`, `_issuer`, `app_store_connect_vendor_number` | A separate App Store Connect API key. Stored for a later product import; not used yet | | `shared_secret` | The legacy app-specific shared secret. Stored but not used: RevenueDot does not call Apple's deprecated verifyReceipt endpoint | ## What you can do from the server afterwards - **Extend a subscription** by 1 to 90 days: `POST /v2/projects/{project_id}/subscriptions/{subscription_id}/actions/extend` with `extend_by_days` and `extend_reason_code`. - **Extend every active subscriber of a product**, for example after an outage: `POST /v2/projects/{project_id}/apps/{app_id}/actions/mass_extend`. - **Refunds** are Apple's decision; customers ask Apple. RevenueDot records Apple's `REFUND` notification as a `CANCELLATION` with a negative price. ## Related - [iOS SDK guide](https://revenuedot.app/docs/sdks/ios.md) - [Test with sandbox accounts and Xcode](https://revenuedot.app/docs/guides/sandbox-testing.md) - [Webhooks](https://revenuedot.app/docs/guides/webhooks.md) - [REST API v2: apps](https://revenuedot.app/docs/api/rest-v2.md) --- # How do I connect Google Play to RevenueDot? Source: https://revenuedot.app/docs/guides/google-play.md Description: Create a Google Play app with your package name, upload a Google Cloud service account key with Play Console access, then push real-time developer notifications to RevenueDot through Pub/Sub. Three steps: create a Google Play app in RevenueDot with your package name, upload a **service account** key that Play Console lets read your orders, and point a **Pub/Sub push subscription** for real-time developer notifications at RevenueDot's notification URL. The dashboard's app page shows the same steps and checks each one. ## 1. Create the app ```bash curl -s -X POST "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/apps" \ -H "Authorization: Bearer $SECRET_KEY" -H "Content-Type: application/json" \ -d '{"name":"Scanner (Android)","type":"play_store","play_store":{"package_name":"com.example.scanner"}}' ``` The app's public SDK key starts with `goog_`. Create its products with `store_identifier` set to `subscriptionId:basePlanId` for subscriptions (for example `pro:monthly`) and the product ID for one-time products. See [Products and entitlements](https://revenuedot.app/docs/concepts/products-and-entitlements.md). ## 2. Add a service account RevenueDot uses the service account to read each purchase from the Google Play Developer API, **acknowledge** it (Google refunds purchases left unacknowledged for 3 days), look up refunds, and run store actions such as refund, cancel and defer. 1. In [Google Cloud](https://console.cloud.google.com/apis/library/androidpublisher.googleapis.com), enable the **Google Play Android Developer API** for your project. 2. Under **IAM → Service accounts**, create a service account. Open it, choose **Keys → Add key → JSON**, and download the file. 3. In [Play Console → Users and permissions](https://play.google.com/console/developers/users-and-permissions), invite the service account's email with **View app information**, **View financial data** and **Manage orders and subscriptions**. 4. In the dashboard, open the app → **Service account credentials**, drop the JSON file and click **Check credentials**. New Play Console permissions can take up to 36 hours to apply; until then the check says the account "works but cannot see this app yet". With the API, send the file's contents as `play_service_account_credentials_json`: ```bash curl -s -X POST "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/apps/$APP_ID" \ -H "Authorization: Bearer $SECRET_KEY" -H "Content-Type: application/json" \ -d "$(jq -n --rawfile sa service-account.json '{play_store: {play_service_account_credentials_json: $sa}}')" curl -s -X POST "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/apps/$APP_ID/actions/verify_credentials" -H "Authorization: Bearer $SECRET_KEY" ``` Without a service account, Google Play receipts cannot be verified and RevenueDot answers 503 (code 7101), so the SDK keeps the purchase and retries once you add it. ## 3. Push real-time developer notifications to RevenueDot Google Play publishes notifications to a Pub/Sub topic. A push subscription delivers them to RevenueDot. 1. Copy the app's notification URL, `https://revenuedot.example.com/v1/notifications/google/{app_id}`, from the dashboard or from `GET /v2/projects/{project_id}/apps/{app_id}/store_settings` (`notification_url`). 2. In [Google Cloud → Pub/Sub](https://console.cloud.google.com/cloudpubsub/topic/list), create a topic. Give `google-play-developer-notifications@system.gserviceaccount.com` the **Pub/Sub Publisher** role on it. 3. Add a subscription to the topic with delivery type **Push** and the notification URL as the endpoint. 4. In Play Console → **Monetize with Play → Monetization setup**, paste the full topic name (`projects//topics/`) and turn on subscriptions, voided purchases and one-time products. 5. Click **Send test notification**. The app's notification status turns **Ready** when it arrives. For each notification RevenueDot stores the raw message once (by message ID), copies it to `notification_forward_url` when set, and reads the purchase again from the Play Developer API. Voided-purchase notifications record refunds. How it answers Pub/Sub: - **200** when the message is handled, a duplicate, for another package name, or about an invalid purchase token. These never succeed on a retry, so Pub/Sub should stop. - **500 or 503** for temporary failures, such as Google's API not answering. Pub/Sub delivers the message again. Once a day RevenueDot also asks Google for voided purchases of the last 30 days, as a backup for missed refund notifications. This needs the service account. ## Optional: authenticate Pub/Sub pushes By default anyone who knows the URL can post to it, but RevenueDot only trusts what Google's API returns for a purchase token, so a fake message cannot unlock anything. To also reject unsigned pushes: 1. Edit the push subscription, turn on **Enable authentication**, choose a service account, and set an **audience** (for example the notification URL). 2. Save the same values on the app. The dashboard has no field for this yet, so use the API: ```bash curl -s -X POST "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/apps/$APP_ID" \ -H "Authorization: Bearer $SECRET_KEY" -H "Content-Type: application/json" \ -d '{"play_store":{"pubsub_audience":"https://revenuedot.example.com/v1/notifications/google/'$APP_ID'","pubsub_service_account":"pubsub-push@your-project.iam.gserviceaccount.com"}}' ``` From then on, a push without a valid Google-signed token for that audience (and that service account) gets 401. ## What you can do from the server afterwards - **Refund and revoke** a subscription: `POST /v2/projects/{project_id}/subscriptions/{subscription_id}/actions/refund`. - **Cancel** (turn auto-renew off): `.../actions/cancel`. - **Defer** the next renewal by up to 365 days: `.../actions/extend` with `extend_by_days`, or v1 `.../subscriptions/{product_id}/defer`. - **Refund a one-time purchase**: `POST /v2/projects/{project_id}/purchases/{purchase_id}/actions/refund`. These call Google's API with the service account. They are tested against a mocked Google API only; run one in a sandbox before you rely on it. ## Related - [Android SDK guide](https://revenuedot.app/docs/sdks/android.md) - [Test with Play license testers](https://revenuedot.app/docs/guides/sandbox-testing.md) - [Store notifications not arriving](https://revenuedot.app/docs/help/store-notifications-not-arriving.md) - [REST API v2](https://revenuedot.app/docs/api/rest-v2.md) --- # How do I receive and verify RevenueDot webhooks? Source: https://revenuedot.app/docs/guides/webhooks.md Description: Add a webhook URL, keep the whsec_ signing secret, verify the X-RevenueCat-Webhook-Signature HMAC on the raw body, answer 200 fast and deduplicate on event.id. Failed deliveries retry 5 times. Add a webhook in the dashboard or with the API and keep the `whsec_...` signing secret it returns. RevenueDot then POSTs each event to your URL as JSON in RevenueCat's webhook format. Verify the `X-RevenueCat-Webhook-Signature` header against the **raw** request body, answer **200** quickly, and ignore events whose `event.id` you have already handled. Anything but 200 is retried after 5, 10, 20, 40 and 80 minutes. ## 1. Add a webhook In the dashboard: **Integrations → Webhooks → Add webhook**. Or with the API: ```bash curl -s -X POST "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/integrations/webhooks" \ -H "Authorization: Bearer $SECRET_KEY" -H "Content-Type: application/json" \ -d '{"name":"Backend","url":"https://api.example.com/webhooks/revenuedot","authorization_header":"Bearer my-shared-token","environment":"production"}' ``` ```json {"object":"webhook_integration","id":"wh_ceps8nr7mczvhaqw","project_id":"proj18pzzkao","name":"Backend","url":"https://api.example.com/webhooks/revenuedot","environment":"production","event_types":[],"app_id":null,"created_at":1790801342625,"signing_secret":"whsec_3f5b7fb5a591c17aaa9108376df0bddbe1555f0a908bfdc0"} ``` - **`signing_secret` is shown once**, in this answer. Store it as a secret in your backend. - `authorization_header` (optional) is sent verbatim as the `Authorization` header, the same role as the authorization header setting in [RevenueCat's webhooks](https://www.revenuecat.com/docs/integrations/webhooks). - `environment`: `production`, `sandbox`, or `null` for both. - `event_types`: lower-case types such as `["initial_purchase","renewal"]`; empty means all. - `app_id`: only one app's events; `null` for all apps. - **Pause without deleting:** send `{"enabled":false}` to `POST /v2/projects/{project_id}/integrations/webhooks/{id}`, or use the Deliveries switch on the webhook's dashboard page. Events recorded while it is paused are not sent; queued retries resume when you turn it back on. `enabled` is a RevenueDot addition; read it with `GET /v2/projects/{project_id}/webhooks`. ## 2. What a delivery looks like ```http POST /webhooks/revenuedot HTTP/1.1 Content-Type: application/json User-Agent: RevenueDot-Webhooks/1.0 Authorization: Bearer my-shared-token X-RevenueCat-Webhook-Signature: t=1790800914,v1=0a1552334e825926036f7efe21527800ea45caa63eca523c6120c6da9041ef99 {"api_version":"1.0","event":{"id":"66339910-3BFF-49F4-B873-D1283D673DE2","type":"INITIAL_PURCHASE","app_user_id":"user_1","original_app_user_id":"user_1","aliases":["user_1"],"product_id":"pro_monthly","entitlement_ids":["pro"],"period_type":"NORMAL","purchased_at_ms":1790800914000,"expiration_at_ms":1793392914000,"environment":"SANDBOX","store":"TEST_STORE","price":9.99,"currency":"USD","presented_offering_id":"default","transaction_id":"test_1790800914000_quickstart","...":"..."}} ``` Every field of every event type, with full examples, is on [Webhook events](https://revenuedot.app/docs/api/webhook-events.md). Handlers written for RevenueCat's webhooks work unchanged: the field names and values are the same. ## 3. Verify the signature The header is `t=,v1=`, where the hex is HMAC-SHA256 of `"."` keyed with the signing secret. RevenueDot signs again on every attempt, so `t` is the attempt's time. 1. Read the raw body bytes. Parsing JSON and serializing it again changes the bytes and breaks the check. 2. Parse `t` and `v1` from the header. 3. Refuse the request if `t` is more than 5 minutes from your clock. This stops replays. 4. Compute the HMAC and compare it to `v1` in constant time. ```js // Node.js. Verifies the HMAC signature on a RevenueDot webhook delivery. import { createHmac, timingSafeEqual } from "node:crypto"; export function verifySignature(rawBody, header, secret, { now = new Date(), toleranceSeconds = 300 } = {}) { const match = /(?:^|,)\s*t=(\d+)\s*,\s*v1=([0-9a-f]{64})\s*(?:,|$)/.exec(header ?? ""); if (!match) return false; const timestamp = Number(match[1]); // Refuse old deliveries so a captured request cannot be replayed. if (Math.abs(Math.floor(now.getTime() / 1000) - timestamp) > toleranceSeconds) return false; const expected = createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest(); const received = Buffer.from(match[2], "hex"); return received.length === expected.length && timingSafeEqual(received, expected); } ``` ```python # Python. Verifies the HMAC signature on a RevenueDot webhook delivery. import hashlib, hmac, re, time _PATTERN = re.compile(r"(?:^|,)\s*t=(\d+)\s*,\s*v1=([0-9a-f]{64})\s*(?:,|$)") def verify_signature(raw_body: bytes, header: str | None, secret: str, tolerance_seconds: int = 300) -> bool: match = _PATTERN.search(header or "") if not match: return False timestamp = int(match.group(1)) # Refuse old deliveries so a captured request cannot be replayed. if abs(int(time.time()) - timestamp) > tolerance_seconds: return False expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, match.group(2)) ``` ```go // Go. Verifies the HMAC signature on a RevenueDot webhook delivery. var signaturePattern = regexp.MustCompile(`(?:^|,)\s*t=(\d+)\s*,\s*v1=([0-9a-f]{64})\s*(?:,|$)`) func VerifySignature(rawBody []byte, header, secret string, now time.Time, tolerance time.Duration) bool { m := signaturePattern.FindStringSubmatch(header) if m == nil { return false } ts, err := strconv.ParseInt(m[1], 10, 64) if err != nil { return false } // Refuse old deliveries so a captured request cannot be replayed. if age := now.Sub(time.Unix(ts, 0)); age > tolerance || age < -tolerance { return false } mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(m[1] + ".")) mac.Write(rawBody) received, err := hex.DecodeString(m[2]) return err == nil && hmac.Equal(received, mac.Sum(nil)) } ``` A complete Express handler that keeps the raw body: ```js // Node.js + Express: receive RevenueDot webhooks. import express from "express"; import { verifySignature } from "./verify.js"; const app = express(); const seen = new Set(); // use your database in production app.post("/webhooks/revenuedot", express.raw({ type: "application/json" }), (req, res) => { if (!verifySignature(req.body, req.get("X-RevenueCat-Webhook-Signature"), process.env.REVENUEDOT_WEBHOOK_SECRET)) { return res.status(401).send("bad signature"); } const { event } = JSON.parse(req.body.toString("utf8")); if (seen.has(event.id)) return res.sendStatus(200); // a retry of an event you already handled seen.add(event.id); // Grant or remove access in your own database here. Keep it fast: RevenueDot waits at most 60 seconds. res.sendStatus(200); }); app.listen(3000); ``` Runnable, tested receivers: [Node.js + Express](https://github.com/revenuedot/examples/tree/main/backend/node-express-webhook), [Next.js](https://github.com/revenuedot/examples/tree/main/backend/nextjs-webhook), [Python + FastAPI](https://github.com/revenuedot/examples/tree/main/backend/python-fastapi-webhook) and [Go](https://github.com/revenuedot/examples/tree/main/backend/go-webhook). If you set an `authorization_header`, also compare the `Authorization` header with it. The HMAC check is the stronger of the two, because it also proves the body was not changed. ## 4. Retries and delivery guarantees - **Only HTTP 200 counts as delivered.** A 201, 204 or redirect is treated as a failure. RevenueCat documents the same rule for its [webhooks](https://www.revenuecat.com/docs/integrations/webhooks). - **Timeout:** 60 seconds per attempt. - **Schedule:** after a failure, RevenueDot retries after 5, 10, 20, 40 and 80 minutes (6 attempts in all), then marks the delivery `failed`. - **At least once:** a delivery can arrive more than once, for example when your 200 is lost. Deduplicate on `event.id`. - **Order is not guaranteed.** Use the timestamps in the event (`event_timestamp_ms`, `purchased_at_ms`, `expiration_at_ms`), or fetch the customer's current state with `GET /v1/subscribers/{app_user_id}` when order matters. - **Every matching webhook gets its own delivery**, so two URLs each receive every event. ## 5. Test and debug - **Send a test event:** dashboard **Send test event**, or `POST /v2/projects/{project_id}/integrations/webhooks/{id}/test`. It sends a purchase-shaped `TEST` event, signed like the others; filters do not apply. - **Make real events with the Test Store:** `POST /v2/projects/{project_id}/test_purchases` with a `scenario` such as `renewal`, `cancel` or `refund` produces the matching events. See [Test Store](https://revenuedot.app/docs/guides/test-store.md). - **Delivery log:** `GET /v2/projects/{project_id}/webhooks/{id}/deliveries?status=failed` shows each attempt's HTTP status, duration and error. Retry one now with `POST .../deliveries/{delivery_id}/retry`. - **Health:** `GET /v2/projects/{project_id}/setup_health` counts deliveries in the last 24 hours and lists failing webhooks. - **Local backend:** from a RevenueDot running in Docker, reach your laptop at `http://host.docker.internal:3000/...`. See [Why are my webhooks not arriving?](https://revenuedot.app/docs/help/webhooks-not-arriving.md) for common causes. ## Related - [Webhook events](https://revenuedot.app/docs/api/webhook-events.md) - [Subscriptions and events](https://revenuedot.app/docs/concepts/subscriptions-and-events.md) - [REST API v2: webhook integrations](https://revenuedot.app/docs/api/rest-v2.md) --- # How do Trusted Entitlements (response signing) work with RevenueDot? Source: https://revenuedot.app/docs/guides/trusted-entitlements.md Description: With REVENUEDOT_SIGNING_KEY set, RevenueDot signs every SDK response the way the RevenueCat SDKs verify. The stock SDK trusts only RevenueCat's key, so turn verification off or use a fork built with your key. The RevenueCat SDKs can check that each response really came from the server, by verifying an Ed25519 signature against a public key built into the SDK. RevenueDot signs its responses in exactly that format when you set `REVENUEDOT_SIGNING_KEY`. **The stock SDK only trusts RevenueCat's key**, so against RevenueDot it reports verification `FAILED`. Turn verification off in the stock SDK, or use a RevenueDot fork that trusts your key. ## What each setup gives you | App uses | Server | Result | |---|---|---| | Stock RevenueCat SDK, verification `DISABLED` | any | No check. Everything works. **Recommended for proxy mode** | | Stock SDK, `INFORMATIONAL` (the iOS and Android default) | any | Entitlements work, but each carries `verification: FAILED` and the SDK logs an error | | Stock SDK, `ENFORCED` | any | Every request fails. Never use it against RevenueDot | | RevenueDot fork (official build) | RevenueDot Cloud | `VERIFIED` | | RevenueDot fork built with your public key | your server with `REVENUEDOT_SIGNING_KEY` | `VERIFIED` | Defaults of the stock SDKs: iOS and Android are informational; Unity defaults to informational in the Inspector; Capacitor passes no mode, so the native default applies; React Native, Flutter and Kotlin Multiplatform default to disabled; Cordova has no setting and logs the failure; purchases-js does not verify. Each [SDK guide](https://revenuedot.app/docs/sdks.md) shows the exact setting. RevenueCat describes the feature in its [Trusted Entitlements docs](https://www.revenuecat.com/docs/customers/trusted-entitlements). ## Turn signing on in your server 1. Generate a key pair in a checkout of the server repository: ```bash pnpm tsx scripts/signing-keygen.ts ``` ```text REVENUEDOT_SIGNING_KEY=3q2+7w...base64 of a 32-byte seed...= public key: ZzwPxGlon0E8ErpDh9QAH0Jh6+E6D6qufvTSetXZY9Y= ``` 2. Give the first line to the server as an environment variable, and keep it in your password manager. See [Self-hosting](https://revenuedot.app/docs/guides/self-hosting.md#set-the-signing-key). Anyone with the seed can sign responses your apps trust. 3. Restart and check the public key: ```bash curl -s http://localhost:8787/.well-known/revenuedot-signing-key ``` ```json {"algorithm":"Ed25519","public_key":"ZzwPxGlon0E8ErpDh9QAH0Jh6+E6D6qufvTSetXZY9Y=","encoding":"base64","header":"X-Signature","docs":"https://revenuedot.app/docs"} ``` Without a key this answers 404 and responses are not signed. ## What gets signed Every 2xx and 3xx response under `/v1` and `/rcbilling` carries an `X-Signature` header. The SDK sends a random `X-Nonce` with requests it verifies, and the nonce is part of the signed message. The value is base64 of 180 bytes, the layout the iOS and Android SDKs verify (`apps/server/src/services/signing.ts`): | Bytes | Content | |---|---| | 0 to 31 | An intermediate Ed25519 public key | | 32 to 35 | The intermediate key's expiry, in days since 1970-01-01, little-endian | | 36 to 99 | The root key's signature over the expiry and the intermediate key | | 100 to 115 | A random salt | | 116 to 179 | The intermediate key's signature over the message | The message is the salt, the API key, the nonce, the request path, the `X-Post-Params-Hash` and `X-Headers-Hash` request headers, the `X-RevenueCat-Request-Time` and `X-RevenueCat-ETag` response headers, and the body. The server makes a new intermediate key every 30 days (7 days before the old one expires) and keeps it in memory; only the root seed is configured. The server's contract tests check this format against real RevenueCat signatures published in the purchases-ios test suite, and check that tampered bodies, nonces, paths, keys and expired intermediate keys are rejected. ## Verify against your own server The official forks trust RevenueDot Cloud's public key (`gXdn2hmqR/TbdtQwK02laE0YgFz0Rtf918LICLrgZhg=`). A self-hosted server cannot sign with that key. To get `VERIFIED` against your own server, build the forks with your public key and, optionally, your host. The fork pipeline in the server repository does it: ```bash # In a checkout of revenuedot/revenuedot, with the fork repositories cloned next to it. pnpm tsx scripts/forks/apply.ts --all \ --var apiHost=https://revenuedot.example.com \ --var signingPublicKey=ZzwPxGlon0E8ErpDh9QAH0Jh6+E6D6qufvTSetXZY9Y= # or: REVENUEDOT_FORK_API_HOST=... REVENUEDOT_FORK_SIGNING_PUBLIC_KEY=... pnpm tsx scripts/forks/apply.ts --all ``` Then build and ship those SDK builds. Rotating the root key means shipping new SDK builds, because the key is compiled into the app. Choose it once and guard it. ## Related - [Why does the SDK report signature verification FAILED?](https://revenuedot.app/docs/help/signature-verification-failed.md) - [How do I connect my app?](https://revenuedot.app/docs/getting-started/connect-your-app.md) - [SDK endpoints: response signing](https://revenuedot.app/docs/api/sdk-endpoints.md) --- # How do I use the Test Store to test purchases without a store account? Source: https://revenuedot.app/docs/guides/test-store.md Description: Add a Test Store app, use its test_ key in the SDK, and buy through the SDK's test dialog. Simulate renewals, cancellations, billing issues and refunds with POST /v2/projects/{id}/test_purchases. The **Test Store** is RevenueDot's built-in store for development. Create a Test Store app, give its `test_` key to the SDK, and purchases go through the SDK's test dialog instead of Apple or Google. They unlock entitlements, record events and send webhooks like real purchases, and they are always **sandbox**. To see a whole lifecycle without waiting, simulate it with `POST /v2/projects/{project_id}/test_purchases`. ## Set it up ```bash B="$REVENUEDOT_URL/v2/projects/$PROJECT_ID"; H="Authorization: Bearer $SECRET_KEY" curl -s -X POST "$B/apps" -H "$H" -H "Content-Type: application/json" -d '{"name":"Test Store","type":"test_store"}' curl -s "$B/apps/$APP_ID/public_api_keys" -H "$H" # -> "key": "test_..." curl -s -X POST "$B/products" -H "$H" -H "Content-Type: application/json" \ -d '{"store_identifier":"pro_monthly","app_id":"'$APP_ID'","type":"subscription","display_name":"Pro monthly","subscription":{"duration":"P1M"}}' ``` Attach the products to your entitlement and offering like any other app's products. The [seed script](https://github.com/revenuedot/examples/blob/main/selfhost/docker-compose/seed.sh) does all of this in one run; see the [Quickstart](https://revenuedot.app/docs/getting-started/quickstart.md). - **The product's `subscription.duration` is the period.** A `P1M` product expires one month after purchase. Without a duration, one month is used. - **The price is the product's Test Store price.** Set it in the dashboard (Edit product, Test Store price) or with `"test_store_price":{"amount_micros":9990000,"currency":"USD"}` in the create or update call. A product without one shows 0. The price the SDK posts with the purchase is recorded. ## Buy in the app Configure the SDK with the `test_` key and your server as the proxy URL, then call `purchase` as usual. The SDK shows its Test Store dialog; choose the successful purchase. The SDK posts `fetch_token = test__` to `POST /v1/receipts`, and RevenueDot accepts any token of that form. The Test Store works with purchases-js, React Native (including Expo Go and the web) and the native SDKs. Use `test_` keys only in development builds; ship your store keys (`appl_`, `goog_`) in releases. Servers built before 2026-09-30 sent product details the native iOS SDK could not read ("No base price found for product"); update the server if you see that. ## Buy with curl ```bash curl -s "$REVENUEDOT_URL/v1/receipts" -H "Authorization: Bearer $TEST_KEY" -H "Content-Type: application/json" \ -d "{\"app_user_id\":\"user_1\",\"fetch_token\":\"test_$(date +%s)000_demo\",\"product_id\":\"pro_monthly\",\"price\":9.99,\"currency\":\"USD\"}" ``` ## Simulate a lifecycle `POST /v2/projects/{project_id}/test_purchases` runs a whole history through the same pipeline as receipts, with each state applied at the time it would have happened. Events, revenue and webhooks come out as they would for a real subscription. ```bash curl -s -X POST "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/test_purchases" \ -H "Authorization: Bearer $SECRET_KEY" -H "Content-Type: application/json" \ -d '{"app_user_id":"user_renewal","product_id":"pro_monthly","scenario":"renewal","price":9.99}' ``` ```json {"object":"test_purchase","scenario":"renewal","store_transaction_id":"test_1790800923978_290d7238-2657-4c28-b829-520e19bc74ec","event_types":["INITIAL_PURCHASE","RENEWAL"],"customer":{"object":"customer","id":"user_renewal","...":"..."},"subscription":{"object":"subscription","status":"active","...":"..."},"purchase":null} ``` | `scenario` | What happens | Default start | Events (monthly product) | |---|---|---|---| | `purchase` | Bought at the start | now | `INITIAL_PURCHASE` | | `trial` | A 7-day free trial starts | now | `INITIAL_PURCHASE` (trial, price 0) | | `trial_conversion` | Trial, then paid periods until now | 7 days ago | `INITIAL_PURCHASE`, `RENEWAL` (`is_trial_conversion`) | | `renewal` | Bought, then renewed every period until now | one period ago | `INITIAL_PURCHASE`, `RENEWAL` | | `cancel` | Like `renewal`, then auto-renew turned off now | now | `INITIAL_PURCHASE`, `CANCELLATION` | | `billing_issue` | The charge fails at the last period end; 7 days of grace | one period ago | `INITIAL_PURCHASE`, `BILLING_ISSUE`, `CANCELLATION` | | `refund` | Like `renewal`, then the latest period is refunded now | now | `INITIAL_PURCHASE`, `CANCELLATION` (refund) | | `expire` | Auto-renew off from the start; access ends at the period end or now | one period ago | `INITIAL_PURCHASE`, `CANCELLATION`, `EXPIRATION` | - `offset_days` (0 to 730) moves the start into the past, for example `"offset_days": 95` with `renewal` on a monthly product gives three renewals. Or send `purchased_at` in epoch milliseconds. - `product_id` is the product's id or store identifier; it must belong to a Test Store app (`app_id` picks one when the project has several). - One-time products support `purchase` and `refund` only. - `price`, `currency` (default USD) and `country_code` are recorded like a real purchase. ## Related - [Sandbox and production](https://revenuedot.app/docs/concepts/sandbox.md) - [Test with App Store sandbox and Google Play testers](https://revenuedot.app/docs/guides/sandbox-testing.md) - [Webhooks](https://revenuedot.app/docs/guides/webhooks.md) - [Extensions: Test Store](https://revenuedot.app/docs/api/extensions.md) --- # How do I test with App Store sandbox, StoreKit in Xcode and Google Play testers? Source: https://revenuedot.app/docs/guides/sandbox-testing.md Description: Real store test purchases work against RevenueDot once the app's store credentials and notifications are set up. They are marked sandbox; Xcode StoreKit purchases also need the StoreKit test certificate. Store test purchases work against RevenueDot the same way they work in production: set up the app's [App Store](https://revenuedot.app/docs/guides/app-store.md) or [Google Play](https://revenuedot.app/docs/guides/google-play.md) credentials and notifications first. RevenueDot reads the environment from the store, so these purchases are marked **sandbox**, unlock entitlements and send webhooks with `environment: SANDBOX`. For quick tests without any store account, use the [Test Store](https://revenuedot.app/docs/guides/test-store.md) instead. **Status (2026-09-30):** the App Store and Google Play code paths pass their test suites against mocked Apple and Google APIs. No real sandbox purchase has run end to end against RevenueDot yet. Tell us what you find in a [GitHub issue](https://github.com/revenuedot/revenuedot/issues). ## App Store sandbox and TestFlight 1. Create a sandbox tester in App Store Connect (**Users and Access → Sandbox**), and sign in with it on the device under **Settings → App Store → Sandbox Account**. TestFlight builds use sandbox purchases too. 2. Configure the SDK with the app's `appl_` key and your RevenueDot proxy URL. 3. Buy. The SDK posts the StoreKit 2 signed transaction; RevenueDot verifies Apple's signature and, with the In-App Purchase key, reads the history from Apple's sandbox environment. 4. Sandbox subscriptions renew fast (a month lasts minutes), so renewals, expirations and their notifications arrive quickly. Point App Store Connect's **Sandbox Server URL** at the same notification URL as production. ## StoreKit testing in Xcode Purchases made with a StoreKit configuration file in Xcode are signed by Xcode, not Apple, so RevenueDot refuses them unless the app holds Xcode's certificate: 1. In Xcode, open the `.storekit` file and choose **Editor → Save Public Certificate**. 2. Save the certificate on the app as `xcode_certificate` (PEM or base64 DER), in the dashboard under **More settings → StoreKit testing in Xcode**, or with the API: ```bash curl -s -X POST "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/apps/$APP_ID" \ -H "Authorization: Bearer $SECRET_KEY" -H "Content-Type: application/json" \ -d "$(jq -n --rawfile cert StoreKitTestCertificate.pem '{app_store: {xcode_certificate: $cert}}')" ``` 3. Buy in the simulator. RevenueDot trusts the local transactions and does not call Apple for them. ## Google Play license testers 1. In Play Console, add testers under **Setup → License testing**, and publish the app to an internal test track. 2. Configure the SDK with the app's `goog_` key and your proxy URL, and install the build from the test track. 3. Buy with a tester account. Google marks the purchase as a test purchase, and RevenueDot records it as sandbox. Test subscriptions renew every few minutes. 4. Pub/Sub notifications for test purchases arrive at the same push endpoint as real ones. ## Check what happened - The customer's page in the dashboard shows each purchase with its environment and events. - `GET /v2/projects/{project_id}/customers/{customer_id}/subscriptions?environment=sandbox` - `GET /v2/projects/{project_id}/events?environment=sandbox` - `GET /v2/projects/{project_id}/metrics/overview?environment=sandbox` ## Related - [Sandbox and production](https://revenuedot.app/docs/concepts/sandbox.md) - [How do I test purchases without real money?](https://revenuedot.app/docs/help/test-sandbox-purchases.md) - [Test Store](https://revenuedot.app/docs/guides/test-store.md) --- # How do I self-host RevenueDot? Source: https://revenuedot.app/docs/guides/self-hosting.md Description: Run one Docker image (API plus dashboard) next to Postgres with docker compose. Configure it with a .env file, put HTTPS in front, and set REVENUEDOT_SIGNING_KEY if you sign responses. Clone the repository, copy `.env.example` to `.env`, and run `docker compose up -d`. You get one container that serves the SDK API, the REST API, store notifications and the dashboard on port 8787, next to Postgres 16 with a persistent volume. The server applies database migrations itself when it starts, so upgrades are a rebuild and a restart. ```bash git clone https://github.com/revenuedot/revenuedot.git cd revenuedot cp .env.example .env # set POSTGRES_PASSWORD before the first start docker compose up -d # builds the image and starts RevenueDot and Postgres curl http://localhost:8787/v1/health # {"status":"ok"} ``` Open `http://localhost:8787/login` and sign up. **The first account is the owner.** After that, sign-up is closed to everyone except the addresses you [invite to a project](https://revenuedot.app/docs/guides/team.md), unless you set `REVENUEDOT_ALLOW_SIGNUP=true`. Set up [email](https://revenuedot.app/docs/guides/self-hosting.md#email) so password resets, invites and alerts reach people. There is no published image yet; Compose builds it from the source. ## What runs | Piece | What it does | |---|---| | `revenuedot` container (Node.js 22) | One process: SDK API (`/v1`), REST API (`/v2`), dashboard sign-in (`/auth`), OAuth for MCP clients (`/oauth`), store notifications (`/v1/notifications/...`) and the dashboard's web app. A background job runs every 30 seconds: it records expirations, runs the daily Google Play voided-purchase check, re-checks store credentials, sends webhooks and sends [alert emails](https://revenuedot.app/docs/guides/alerts.md) | | `db` container (Postgres 16) | Every customer, purchase, event and setting, in the `revenuedot-data` volume | Run **one** `revenuedot` container per database for now. The background job has no lock across processes, so two containers could send a webhook twice. ## Settings Edit `.env` next to `docker-compose.yml`. Store credentials (Apple keys, Google service accounts) are not environment variables: each app holds its own, set in the dashboard or with the REST API. | Variable | Default | What it does | |---|---|---| | `POSTGRES_PASSWORD` | `revenuedot` in Compose | Password of the bundled Postgres. Set it before the first start: it is written into the volume then, and changing it later also needs `ALTER USER` in Postgres | | `REVENUEDOT_PORT` | `8787` | Host port for everything | | `REVENUEDOT_ALLOW_SIGNUP` | `false` | `true` lets anyone who reaches the dashboard create an account. Invited addresses can always create one | | `REVENUEDOT_SMTP_URL`, `REVENUEDOT_MAIL_FROM`, `REVENUEDOT_MAIL_REPLY_TO`, `REVENUEDOT_PUBLIC_URL` | unset | Outgoing email. See [Email](https://revenuedot.app/docs/guides/self-hosting.md#email) | | `REVENUEDOT_SIGNING_KEY` | unset | Base64 Ed25519 seed. Turns on [response signing](https://revenuedot.app/docs/guides/trusted-entitlements.md). Not passed through by the default `docker-compose.yml`; see below | Inside the container the server reads: | Variable | Default | What it does | |---|---|---| | `DATABASE_URL` | `pglite://./.data/dev` | `postgres://user:password@host:5432/db` for Postgres (Compose sets it). A `pglite://` path uses an embedded Postgres on disk, for development only | | `PORT` | `8787` | Port the server listens on | | `DASHBOARD_DIST` | the built dashboard in the image | Folder of the dashboard's built files. If it has no `index.html`, only the API is served | | `REVENUEDOT_ALLOW_SIGNUP` | unset (owner only) | See above | | `REVENUEDOT_SIGNING_KEY` | unset | See above | | `REVENUEDOT_SMTP_URL` and the other mail variables | unset | See [Email](https://revenuedot.app/docs/guides/self-hosting.md#email) | ### Set the signing key Generate a key once (`pnpm tsx scripts/signing-keygen.ts` in a checkout with `pnpm install` done), add it to `.env`, and pass it to the container with a `docker-compose.override.yml`, which Compose reads automatically: ```yaml # docker-compose.override.yml services: revenuedot: environment: REVENUEDOT_SIGNING_KEY: ${REVENUEDOT_SIGNING_KEY} ``` Check it with `curl http://localhost:8787/.well-known/revenuedot-signing-key`. Stock RevenueCat SDKs still cannot verify these signatures; see [Trusted Entitlements](https://revenuedot.app/docs/guides/trusted-entitlements.md). ## Email RevenueDot sends email for password resets, [team invites](https://revenuedot.app/docs/guides/team.md) and [alerts](https://revenuedot.app/docs/guides/alerts.md). A self-hosted server sends it through any SMTP provider (your own mail server, Amazon SES, Postmark, Resend, Mailgun, SendGrid and others). Add these to `.env`; the default `docker-compose.yml` passes them to the container: ```bash REVENUEDOT_SMTP_URL=smtp://user:password@smtp.example.com:587 REVENUEDOT_MAIL_FROM=RevenueDot REVENUEDOT_MAIL_REPLY_TO=team@example.com REVENUEDOT_PUBLIC_URL=https://revenuedot.example.com ``` | Variable | What it does | |---|---| | `REVENUEDOT_SMTP_URL` | The SMTP server. `smtp://` connects on port 587 and upgrades to TLS with STARTTLS when the server offers it. `smtps://` uses TLS from the start, on port 465. Write special characters in the user name or password URL-encoded: `@` is `%40`, `:` is `%3A`, `/` is `%2F` | | `REVENUEDOT_MAIL_FROM` | The sender, as `Name
` or a bare address. Use an address on a domain your SMTP provider may send for (SPF and DKIM set up), or the emails land in spam. Default: `RevenueDot `, which most providers reject | | `REVENUEDOT_MAIL_REPLY_TO` | Optional. Where replies go | | `REVENUEDOT_PUBLIC_URL` | The address people use to open the dashboard, for the links in emails. Unset: links use the address the request came in on (`X-Forwarded-Host` behind a proxy). Alert emails have no request, so they use the last address the dashboard was opened on since the server started, or `http://localhost:8787`. Set it for correct alert links | **Without `REVENUEDOT_SMTP_URL`, nothing is sent.** Every email, links included, is printed to the server log instead, so you can still copy a reset or invite link: ```bash docker compose logs revenuedot ``` When the server starts, its log says which it does: `Email: SMTP (REVENUEDOT_SMTP_URL).` or `Email: not configured; emails are printed to this log.` A failed send is logged with the SMTP error and never blocks sign-up, invites or the background job. ## Reset a password without email When someone cannot get a reset email, reset the password straight in the database with the `revenuedot` CLI. It needs `DATABASE_URL`, the server's Postgres: ```bash DATABASE_URL=postgres://revenuedot:secret@localhost:5432/revenuedot npx revenuedot admin reset-password dev@example.com ``` With Docker Compose, run it inside the server container, which has `DATABASE_URL` set already: ```bash docker compose exec revenuedot pnpm --filter revenuedot cli admin reset-password dev@example.com ``` - **Without `--password`**, the CLI generates a 20-character password and prints it once. Copy it then; it is not stored anywhere readable. - **`--password `** sets a password you choose (at least 8 characters). - **Every session of the user is signed out**, and open reset links stop working. - **`--database-url `** can replace the `DATABASE_URL` variable. ## Put HTTPS in front The App Store and Google Pub/Sub send notifications only to public HTTPS URLs, and apps should never talk to your server over plain HTTP. Put a reverse proxy with TLS in front of port 8787, for example Caddy: ```text # Caddyfile revenuedot.example.com { reverse_proxy localhost:8787 } ``` RevenueDot builds the notification URLs and the SDK's proxy URL it shows in the dashboard from `X-Forwarded-Host` and `X-Forwarded-Proto`. Caddy, nginx and most load balancers send these; check the app page shows `https://revenuedot.example.com/v1/notifications/...`. Session cookies get the `Secure` flag when the request URL is HTTPS. ## Use your own Postgres Point `DATABASE_URL` at any Postgres 16 database and remove the `db` service, or run the server without Docker: ```bash pnpm install pnpm --filter @revenuedot/dashboard build DATABASE_URL=postgres://revenuedot:secret@db.internal:5432/revenuedot pnpm --filter @revenuedot/server start ``` The server creates its tables on the first start. Managed Postgres (RDS, Cloud SQL, Neon, Railway, Supabase) works; the server keeps a pool of 10 connections. ## Local development Without `DATABASE_URL`, `pnpm dev` stores data in an embedded Postgres (PGlite) under `.data/dev`, so you need no database server to try changes: ```bash pnpm install pnpm --filter @revenuedot/dashboard build # optional: without it only the API is served pnpm dev # http://localhost:8787, restarts on changes ``` ## Related - [Upgrades](https://revenuedot.app/docs/guides/upgrades.md) and [Backups](https://revenuedot.app/docs/guides/backups.md) - [Invite your team](https://revenuedot.app/docs/guides/team.md) and [Alert emails](https://revenuedot.app/docs/guides/alerts.md) - [Going to production](https://revenuedot.app/docs/guides/going-to-production.md) - [Quickstart](https://revenuedot.app/docs/getting-started/quickstart.md) - [Self-host in 5 minutes](https://revenuedot.app/blog/self-host-revenuedot-in-5-minutes.md) --- # How do I upgrade a self-hosted RevenueDot? Source: https://revenuedot.app/docs/guides/upgrades.md Description: Back up the database, pull the new source, rebuild and restart. The server applies new database migrations by itself when it starts; there is no separate migrate step. Back up, pull, rebuild, restart. The server applies any new database migrations when it starts, before it accepts requests, so there is no separate migration command. Your `.env` stays as it is. ```bash cd revenuedot docker compose exec -T db pg_dump -U revenuedot -Fc revenuedot > revenuedot-before-upgrade.dump # 1. back up git pull # 2. new source docker compose up -d --build # 3. rebuild and restart curl -s http://localhost:8787/v1/health # 4. {"status":"ok"} ``` ## What happens during the restart - The old container stops and the new one starts. Requests in between fail for a few seconds. - **The SDKs handle this.** A failed receipt post is kept on the device and retried, and customer info comes from the SDK's cache. - **Stores retry.** Apple and Google resend notifications that got no 2xx answer. - **Webhooks wait.** Pending deliveries stay in the database and go out after the restart. For no downtime at all, run the new version next to the old one against the same database only after reading the release notes, which list any breaking change and the steps it needs. ## If the new version does not start 1. Read the log: `docker compose logs revenuedot --tail 100`. A failed migration names the statement. 2. Go back to the previous commit and rebuild: `git checkout && docker compose up -d --build`. 3. If a migration already ran, restore the backup as described in [Backups](https://revenuedot.app/docs/guides/backups.md#restore), then start the previous version. ## Keep up with changes - Watch the [server repository](https://github.com/revenuedot/revenuedot) for commits and, later, releases. - Migrations live in `packages/db/migrations`. Each upgrade applies the ones your database has not seen, in order. - Accounts made by builds from before 2026-09-30 used 210,000 password-hashing iterations; newer builds use 100,000. Both verify on a self-hosted server, so nobody needs to reset a password. ## Related - [Backups](https://revenuedot.app/docs/guides/backups.md) - [Self-hosting](https://revenuedot.app/docs/guides/self-hosting.md) - [Going to production](https://revenuedot.app/docs/guides/going-to-production.md) --- # How do I back up and restore a self-hosted RevenueDot? Source: https://revenuedot.app/docs/guides/backups.md Description: Everything lives in Postgres. Back it up with pg_dump on a schedule, keep copies off the server, and restore with pg_restore while the RevenueDot container is stopped. All of RevenueDot's state is in Postgres: projects, apps and their store credentials, the catalog, customers, purchases, events, webhook deliveries and API key hashes. Back up the database with `pg_dump` on a schedule and keep the copies somewhere other than the server. The container itself holds nothing you need to keep. ## Back up ```bash # A compressed custom-format dump of the bundled Postgres docker compose exec -T db pg_dump -U revenuedot -Fc revenuedot > revenuedot-$(date +%F).dump ``` Run it daily, for example from cron, and copy the file to object storage: ```cron 15 3 * * * cd /srv/revenuedot && docker compose exec -T db pg_dump -U revenuedot -Fc revenuedot > /backups/revenuedot-$(date +\%F).dump ``` - **The dump contains secrets.** App store credentials (the App Store `.p8` key, Google service account JSON) and webhook signing secrets are stored in the database. Encrypt backups and limit who can read them. - **Managed Postgres** (RDS, Cloud SQL, Neon, Supabase and others) has its own point-in-time recovery. Turn it on; it covers you between dumps. - **Store the signing key separately.** `REVENUEDOT_SIGNING_KEY` is not in the database. Keep it in your password manager. ## Restore Stop the API, restore, start it again: ```bash docker compose stop revenuedot docker compose exec -T db pg_restore -U revenuedot -d revenuedot --clean --if-exists < revenuedot-2026-09-30.dump docker compose start revenuedot ``` After a restore, the database is as it was at the dump. Purchases made since then come back by themselves: - Apps post receipts again on their next `syncPurchases()`, restore or purchase. - Store notifications that failed while the server was down are retried by Apple and Google. - For a long gap, re-run the migration importer from RevenueCat if you are still in a [dual run](https://revenuedot.app/docs/migrate/dual-run.md), or ask users to restore purchases. ## Start over ```bash docker compose down -v # deletes the volume: every customer and purchase ``` ## Related - [Upgrades](https://revenuedot.app/docs/guides/upgrades.md) - [Self-hosting](https://revenuedot.app/docs/guides/self-hosting.md) - [Going to production](https://revenuedot.app/docs/guides/going-to-production.md) --- # What should I check before running RevenueDot in production? Source: https://revenuedot.app/docs/guides/going-to-production.md Description: A checklist for a self-hosted RevenueDot with real customers - HTTPS, a real Postgres with backups, store credentials and notifications verified, webhooks tested, secrets kept out of apps, and monitoring. Work through this list before real customers depend on your server. Run a real sandbox purchase on each store first, and consider a [dual run](https://revenuedot.app/docs/migrate/dual-run.md) next to your current system. ## Server - [ ] HTTPS in front of the server, with a certificate that renews itself. See [Self-hosting](https://revenuedot.app/docs/guides/self-hosting.md#put-https-in-front). - [ ] A real Postgres (`DATABASE_URL=postgres://...`), not the embedded PGlite. - [ ] `POSTGRES_PASSWORD` changed from the default before the first start. - [ ] Daily backups copied off the server, and one test restore done. See [Backups](https://revenuedot.app/docs/guides/backups.md). - [ ] One `revenuedot` container per database. - [ ] Sign-up closed (`REVENUEDOT_ALLOW_SIGNUP` unset); add teammates with [invites](https://revenuedot.app/docs/guides/team.md) instead. - [ ] `REVENUEDOT_SMTP_URL`, `REVENUEDOT_MAIL_FROM` and `REVENUEDOT_PUBLIC_URL` set, and one password reset email received, so resets, invites and [alert emails](https://revenuedot.app/docs/guides/alerts.md) reach people. See [Email](https://revenuedot.app/docs/guides/self-hosting.md#email). - [ ] Uptime monitoring on `GET /v1/health`, and alerts on the container's restarts. - [ ] `REVENUEDOT_SIGNING_KEY` set and stored in a password manager, if you ship SDK builds that verify responses. See [Trusted Entitlements](https://revenuedot.app/docs/guides/trusted-entitlements.md). ## Stores - [ ] Each store app has its credentials, and **Check credentials** says valid: the App Store In-App Purchase key, the Google Play service account. - [ ] Store notifications arrive: the app's status is **Ready** in the dashboard, or `notification_status: "ready"` in `GET /v2/projects/{project_id}/setup_health`. - [ ] App Store Connect has RevenueDot's URL as both the Production and Sandbox Server URL, Version 2. - [ ] Google Play's Pub/Sub topic has a push subscription to RevenueDot, and Play Console's test notification arrived. - [ ] Every product's `store_identifier` matches the store exactly (Google Play subscriptions as `subscriptionId:basePlanId`), and each product is attached to the right entitlement and package. ## Apps - [ ] The SDK's proxy URL is your HTTPS URL, set before `configure`. - [ ] Entitlement verification is `DISABLED` with the stock SDK, or the app uses a fork built with your key. - [ ] Release builds use the store keys (`appl_`, `goog_`), never a `test_` key. - [ ] No secret key (`sk_...`) anywhere in an app. ## Your backend - [ ] Webhooks verify the `X-RevenueCat-Webhook-Signature` HMAC and deduplicate on `event.id`. See [Webhooks](https://revenuedot.app/docs/guides/webhooks.md). - [ ] A test event reached your backend and was answered 200. - [ ] Secret keys used by your backend have only the permissions they need. See [Authentication](https://revenuedot.app/docs/api/authentication.md). - [ ] Failed deliveries are watched: `GET /v2/projects/{project_id}/setup_health` lists failing webhooks. ## Related - [Cutover checklist for a migration](https://revenuedot.app/docs/migrate/cutover-checklist.md) - [Known issues](https://revenuedot.app/docs/help/known-issues.md) - [Troubleshooting](https://revenuedot.app/docs/help/troubleshooting.md) --- # How do I invite my team to a RevenueDot project? Source: https://revenuedot.app/docs/guides/team.md Description: Project admins invite people by email with the Admin, Developer or Viewer role. The link lasts 7 days. Admins change roles and remove members; a project always keeps one admin. Open **Project settings → Collaborators** and click **Invite**. Enter the person's email address, pick a role and send. They get an email with a link that works for 7 days. Only project admins can invite, change roles and remove people. Everyone in a project is a **member** with one role in that project; a person can have a different role in each project. ## Roles The roles follow RevenueCat's collaborator roles ([Collaborators](https://www.revenuecat.com/docs/projects/collaborators)). RevenueDot has three of them today. | Role | Can do | Cannot do | Name in API v2 | |---|---|---|---| | **Admin** | Everything: apps, store credentials, products, entitlements, offerings, customers, webhooks, secret API keys, invites, members, deleting the project | Nothing is off limits | `admin` | | **Developer** | Read everything. Edit apps, store credentials, the catalog, customers, webhooks and project settings | Create or revoke secret API keys, invite people, change roles, remove other members, delete the project | `developer` | | **Viewer** | Read everything the dashboard shows | Change anything | `read_only` (RevenueCat's "View Only") | `GET /v2/projects/{project_id}/collaborators` answers with RevenueCat's role names, so the Viewer role comes back as `read_only`. Requests that set a role take `admin`, `developer` or `viewer`. ## Invite someone 1. Open **Project settings → Collaborators** and click **Invite**. 2. Enter the email address and pick **Admin**, **Developer** or **Viewer**. 3. Click **Send invite**. The invite shows under **Pending invites** until the person accepts it. - **The link lasts 7 days.** An expired invite stays in the list, so you can send it again. - **Resend** sends a new link that lasts another 7 days. The old link stops working. - **Revoke** makes the link stop working at once. You can invite the same address again later. - **Inviting an address that already has a pending invite** replaces it: the new role applies, and only the newest link works. - **Inviting someone who is already a member** fails with a message that they are already in the project. - **A project can send 50 invites a day**, resends included. After that, try again the next day. - **On RevenueDot Cloud, you need a confirmed email address to invite people.** A banner at the top of the dashboard offers a new confirmation email if you lost the first one. Self-hosted servers treat every account as confirmed. If the dashboard says the invite was saved but the email could not be sent, the mail server refused it. Fix the mail settings, then click **Resend**. On a self-hosted server without email, the invite link is printed to the server log; see [Email](https://revenuedot.app/docs/guides/self-hosting.md#email). ## Accept an invite The link in the email opens the invite page. It shows the project, the role and who invited you. - **You already have an account:** sign in with the invited address and click **Accept invite**. If you are signed in with another address, sign out and sign in with the invited one. If you are already a member of the project, you keep your current role. - **You are new:** create an account on the invite page. The email address is fixed to the invited one, and it counts as confirmed, so you get no confirmation email. You join the project instead of getting an empty one. A self-hosted server closes sign-up after the first account (the owner). An invite still lets the invited address create an account. Nobody else can sign up unless the server runs with `REVENUEDOT_ALLOW_SIGNUP=true`. ## Change a role, remove a member, or leave - **Change a role:** admins pick a new role in the member's row. - **Remove a member:** admins open the member's menu and click **Remove from project**. The person loses access at once. - **Leave a project:** any member can open their own menu and click **Leave project**. - **A project always keeps at least one admin.** The last admin cannot be demoted, removed or leave. Make someone else an admin first, or delete the project. ## Do it with the API These are RevenueDot extensions. They need a dashboard session (the `rd_session` cookie from `POST /auth/login`); secret API keys cannot manage members. See [Members and invites](https://revenuedot.app/docs/api/extensions.md#members-and-invites) for request and response details. | Task | Request | |---|---| | List members | `GET /v2/projects/{project_id}/collaborators` | | List pending and expired invites | `GET /v2/projects/{project_id}/invites` | | Invite by email | `POST /v2/projects/{project_id}/invites` with `{"email": "sam@example.com", "role": "developer"}` | | Resend an invite | `POST /v2/projects/{project_id}/invites/{invite_id}/actions/resend` | | Revoke an invite | `DELETE /v2/projects/{project_id}/invites/{invite_id}` | | Change a member's role | `POST /v2/projects/{project_id}/collaborators/{user_id}` with `{"role": "viewer"}` | | Remove a member, or leave (your own `user_id`) | `DELETE /v2/projects/{project_id}/collaborators/{user_id}` | | Look up an invite from its link | `GET /auth/invites/{token}` (no session) | | Accept as an existing user | `POST /auth/invites/{token}/accept` | | Sign up from an invite | `POST /auth/signup` with `invite_token` | ## Related - [Alert emails](https://revenuedot.app/docs/guides/alerts.md): what admins get told when something breaks - [Self-hosting: Email](https://revenuedot.app/docs/guides/self-hosting.md#email) - [Projects, apps and API keys](https://revenuedot.app/docs/concepts/projects-and-apps.md) --- # Which alert emails does RevenueDot send? Source: https://revenuedot.app/docs/guides/alerts.md Description: Project admins get an email when store notifications fail, a webhook fails 5 times in a row, or Apple or Google rejects the store credentials. One email when it starts, at most one reminder a day, one when it is fixed. RevenueDot emails a project's admins when one of three things breaks: **store notifications are failing**, **a webhook keeps failing**, or **Apple or Google rejects the app's store credentials**. You get one email when the problem starts, at most one reminder a day while it lasts, and one email when it is fixed. Each email links to the app or webhook page that shows the details. ## The three alerts | Alert | When it starts | When it ends | |---|---|---| | **Store notifications failing** | An App Store, Mac App Store or Google Play app's setup health says notifications are **failing**: the newest notification from the store could not be processed (for example a bad signature, another bundle ID or package name, or an invalid purchase token) | The next notification from the store is processed | | **Webhook failing** | The last 5 delivery attempts to one webhook all failed. Paused webhooks do not alert | A delivery succeeds, or you pause the webhook | | **Store credentials failing** | Apple answers 401 to the app's in-app purchase key, or Google answers 401 or 403 to the service account. This counts on any call: receipt checks, store notifications, the Google Play voided-purchases scan and the dashboard's **Check credentials** button | A check with the store succeeds | RevenueDot checks the store credentials of a failing app again every hour, and the credentials of every app once a day. So a fixed key clears the alert within an hour, and a key revoked in App Store Connect or Google Cloud raises one within a day even when no purchase comes in. Deleting the app or the webhook closes its alert without a "fixed" email. ## Who gets them - **Every admin of the project** gets the emails. Developers and Viewers do not. See [roles](https://revenuedot.app/docs/guides/team.md#roles). - **Each admin can turn them off** in **Account settings** (`/account`, in the menu under your name): switch off **Email me about problems with my projects**. The setting covers every project you administer. - **With the API:** `POST /auth/me` with `{"alert_emails": false}` turns them off, and `true` turns them back on. `GET /auth/me` shows the current value in `user.alert_emails`. See [Update account settings](https://revenuedot.app/docs/api/extensions.md#update-account-settings). ## How often - **One email when the alert opens.** - **At most one reminder every 24 hours** while it stays open. - **One email when it resolves.** RevenueDot looks for problems about every minute on RevenueDot Cloud, and every 30 seconds on a self-hosted server. ## On a self-hosted server Alert emails need mail settings. Without `REVENUEDOT_SMTP_URL`, each alert email is printed to the server log instead of sent (`docker compose logs revenuedot`). Set `REVENUEDOT_PUBLIC_URL` so the links in the email point at your dashboard. See [Email](https://revenuedot.app/docs/guides/self-hosting.md#email). ## Related - [Why are store notifications not arriving?](https://revenuedot.app/docs/help/store-notifications-not-arriving.md) - [Why are webhooks not arriving?](https://revenuedot.app/docs/help/webhooks-not-arriving.md) - [Connect the App Store](https://revenuedot.app/docs/guides/app-store.md) and [Connect Google Play](https://revenuedot.app/docs/guides/google-play.md) - [Webhooks](https://revenuedot.app/docs/guides/webhooks.md) === Migrate from RevenueCat === # How do I migrate from RevenueCat to RevenueDot? Source: https://revenuedot.app/docs/migrate.md Description: Import your project with the revenuedot CLI, run both systems side by side with notification forwarding, ship an app update that sets the proxy URL, then cut over. No customer loses access. Move in four phases: import the project, run both systems side by side, ship an app update that points the SDK at RevenueDot, then cut over. The importer copies your catalog, customers and purchases and keeps your SDK keys, so no customer loses access on switch day. **The importer is in the RevenueDot repository today but not on npm yet (2026-09-30)**; you run it from source. ## The four phases 1. **Import and set up RevenueDot next to RevenueCat.** - Run the [importer](https://revenuedot.app/docs/migrate/importer.md): catalog, customers, subscriptions, purchases and the public SDK keys. - Enter each app's store credentials in RevenueDot (App Store in-app purchase key, Google Play service account). They cannot be exported from RevenueCat. - Run the importer again, so it confirms Apple transaction ids and looks up Google purchase tokens with those credentials. - Check the result with `import verify`. 2. **Route store notifications through RevenueDot.** Point App Store Server Notifications at RevenueDot and forward them to RevenueCat. For Google Play, add a second Pub/Sub push subscription or forward the same way. Keep acting on RevenueCat's webhooks. See [Dual run](https://revenuedot.app/docs/migrate/dual-run.md). 3. **Ship the app update.** Set the SDK's proxy URL, turn off signature checks, and call `syncPurchases()` once on first launch. Users still on older versions keep talking to RevenueCat, which stays correct because notifications are forwarded. Keep re-running the importer while they do. See [SDK changes](https://revenuedot.app/docs/migrate/sdk-changes.md). 4. **Cut over.** When `import verify` shows no differences and few users run old versions, create your webhooks in RevenueDot, turn off RevenueCat's, remove the forwarding URLs, and turn RevenueCat off. See the [cutover checklist](https://revenuedot.app/docs/migrate/cutover-checklist.md). ## What the importer brings over | Data | Notes | |---|---| | Apps | Matched by store and bundle id or package name; created if missing | | Public SDK keys (`appl_`, `goog_`, ...) | Each app's production key, so app builds you already shipped keep working | | Products, entitlements, offerings, packages | With product attachments, metadata, positions and the current offering | | Customers and aliases | First and last seen dates, platform, country, app version; customers that share an alias merge into one | | Attributes | With their original update times | | Subscriptions | Current period, status, auto-renew, grace period, billing issues, sandbox flag, family sharing, price and every store transaction id | | One-time purchases | Transaction id, date, price, refunds, consumable or not | | Promotional access | As promotional grants for the same entitlements | | Revenue history | One row per store transaction, for charts | The import records **no events and sends no webhooks**, so your backend does not see a second copy of every old purchase. Pass `--emit-events` if you want them. ## What it does not bring over - **Store credentials.** RevenueCat's API does not return them. The import report lists every app that needs them. - **Paywalls, targeting rules, experiments and virtual currency balances.** RevenueDot does not have these features yet; see [What differs](https://revenuedot.app/docs/migrate/what-differs.md). - **Integrations other than webhooks, and the webhooks themselves.** Create webhooks in RevenueDot at cutover. - **Refunds of subscriptions.** RevenueCat's [API v2](https://www.revenuecat.com/docs/api-v2) subscription object does not show them, so a refunded subscription imports as expired. - **RevenueCat Billing renewals.** Current access is imported, but those subscriptions keep renewing through RevenueCat. - **Google Play purchase tokens.** RevenueCat's API gives order ids only. RevenueDot looks the tokens up with your service account, from a CSV you pass, from Google's next renewal notification, or from the app's `syncPurchases()`. Access is kept in the meantime. See [Google Play purchase tokens](https://revenuedot.app/docs/migrate/importer.md#google-play-purchase-tokens). ## What is tested - The importer runs in tests against a fake RevenueCat API, whose responses are checked against RevenueCat's published OpenAPI schemas, and the real RevenueDot server. - Store notification forwarding was verified: a notification sent to RevenueDot reached the forwarding URL byte for byte. - No real App Store or Google Play sandbox purchase has run end to end yet; store handling is tested against mocked Apple and Google APIs. ## Related - [The importer](https://revenuedot.app/docs/migrate/importer.md) - [Dual run](https://revenuedot.app/docs/migrate/dual-run.md) - [SDK changes](https://revenuedot.app/docs/migrate/sdk-changes.md) - [Cutover checklist](https://revenuedot.app/docs/migrate/cutover-checklist.md) - [What differs from RevenueCat](https://revenuedot.app/docs/migrate/what-differs.md) - [Connect your app](https://revenuedot.app/docs/getting-started/connect-your-app.md) --- # How do I import a RevenueCat project with the revenuedot CLI? Source: https://revenuedot.app/docs/migrate/importer.md Description: revenuedot import copies apps, SDK keys, catalog, customers, subscriptions and purchases from RevenueCat's API v2 into RevenueDot. It resumes after a stop and is safe to run again. Run `revenuedot import --from-revenuecat` with a RevenueCat v2 secret key and a RevenueDot secret key. It reads your RevenueCat project through RevenueCat's REST API v2 and writes it into RevenueDot: apps, public SDK keys, products, entitlements, offerings, packages, customers, aliases, attributes, subscriptions and one-time purchases. It sends no webhooks, resumes where it stopped, and a second run changes nothing that is already right. **The CLI is on npm as [`revenuedot`](https://www.npmjs.com/package/revenuedot)** (Node.js 18.17 or newer), so `npx revenuedot` runs the latest release. ## Run it ```bash npx revenuedot import --from-revenuecat --rc-key sk_... --rc-project proj... \ --to https://revenuedot.example.com --to-key sk_... ``` From source instead (needs pnpm): ```bash git clone https://github.com/revenuedot/revenuedot && cd revenuedot && pnpm install pnpm --filter revenuedot cli import --from-revenuecat \ --rc-key sk_... --rc-project proj... \ --to https://revenuedot.example.com --to-key sk_... ``` You need: 1. **A RevenueCat secret API key, version 2**, with read access to project configuration (apps, products, entitlements, offerings, packages) and customer information (customers, subscriptions, purchases). A RevenueCat OAuth token (`atk_...`) also works. A public SDK key does not. 2. **Your RevenueCat project id** (`proj...`). It is in the RevenueCat dashboard URL. 3. **A running RevenueDot server and a secret key** (`sk_...`) for the project you import into. Create one on the dashboard's **API keys** page. See [Which key goes where](https://revenuedot.app/docs/concepts/projects-and-apps.md#which-key-goes-where). ## Commands | Command | What it does | Needs | |---|---|---| | `revenuedot import --from-revenuecat` | Imports the catalog, then customers page by page | `--rc-key`, `--rc-project`, `--to`, `--to-key` | | `revenuedot import verify` | Compares every customer's active entitlements between RevenueCat and RevenueDot | `--rc-key`, `--rc-project`, `--to`, `--to-key` | | `revenuedot import plan` | Prints the cutover steps with your app ids and URLs filled in | `--to`, `--to-key` (`--rc-project` optional) | ## Flags | Flag | Meaning | Default | |---|---|---| | `--rc-key ` | RevenueCat secret key, v2 (`sk_...`) or OAuth token (`atk_...`) | `REVENUECAT_API_KEY` | | `--rc-project ` | RevenueCat project id | `REVENUECAT_PROJECT_ID` | | `--to ` | Your RevenueDot server, e.g. `http://localhost:8787` | `REVENUEDOT_URL` | | `--to-key ` | RevenueDot secret key of the target project | `REVENUEDOT_API_KEY` | | `--to-project ` | RevenueDot project id | the key's project | | `--state ` | State file for resuming | `./revenuedot-import-.json` | | `--dry-run` | Read everything and report what would change; write nothing | off | | `--restart` | Ignore the state file and start from the first customer | off | | `--concurrency ` | Customers fetched in parallel | 4 | | `--limit ` | Import only the first n customers, for a trial run | all | | `--google-tokens ` | Google Play purchase tokens you already have | none | | `--no-public-keys` | Keep RevenueDot's own SDK keys instead of copying RevenueCat's | off (keys are copied) | | `--emit-events` | Record lifecycle events and send webhooks for imported purchases | off | | `--json` | Print the report as JSON | off | | `-h`, `--help` | Show help | | Two more flags exist for testing and are not in `--help`: `--rc-url ` (RevenueCat's API base, default `https://api.revenuecat.com`) and `--page-size ` (customers per RevenueCat page, default 100). `--from-revenuecat` names the source; it is accepted but not required. **Environment variables** keep keys out of your shell history: `REVENUECAT_API_KEY`, `REVENUECAT_PROJECT_ID`, `REVENUEDOT_URL`, `REVENUEDOT_API_KEY`. A flag wins over its variable. **Exit codes:** `0` success; `1` failure, or differences found by `import verify`; `2` wrong usage, such as a missing flag or a public key passed as `--rc-key`. Source: [`packages/importer/src/cli.ts`](https://github.com/revenuedot/revenuedot/blob/main/packages/importer/src/cli.ts). ## Start with a dry run ```bash npx revenuedot import --from-revenuecat --rc-key sk_... --rc-project proj... \ --to http://localhost:8787 --to-key sk_... --dry-run ``` A dry run reads the whole RevenueCat project and prints what it would create. It writes nothing to RevenueDot and does not save a state file. Add `--limit 50` to try the real import on 50 customers first. ## Stop and resume at any time - Progress shows on one line on stderr. The report goes to stdout. - The state file (`revenuedot-import-.json` by default) records the catalog mapping, the last customer page that finished, the counts and the problems found. - If the run stops (network error, Ctrl-C, a closed laptop), run the same command again. It continues after the last finished page. - When a pass has finished, running the command again starts a new full pass. The catalog is always re-synced. Because the import is idempotent, this is how you keep RevenueDot current during the [dual run](https://revenuedot.app/docs/migrate/dual-run.md); a daily run is safe. - A state file belongs to one RevenueCat project and one RevenueDot project. Use `--state` with another file, or `--restart`, to import somewhere else. **Speed:** about 5 requests per customer. RevenueCat allows 480 requests a minute ([rate limits](https://www.revenuecat.com/docs/api-v2)), so expect about 90 customers a minute (100,000 customers take about 18 hours). On a 429 the importer waits for `Retry-After`, and it retries 5xx answers. ## Read the report ```text Import finished: RevenueCat project proj1ab2c3d4 -> https://revenuedot.example.com (project projujvzn2wl). Catalog Apps 2 created, 0 already there Products 6 created, 0 already there Entitlements 1 created, 0 already there Offerings 2 created, 0 already there Packages 4 created, 0 already there SDK keys 2 kept (existing app builds keep working with RevenueDot) Customers (pass 1, complete) 1200 customers imported (1180 new, 20 merged with existing ones) 950 subscriptions, 130 one-time purchases 310 Google Play subscriptions need a purchase token (add the service account and run again; see "revenuedot import plan") Store credentials to re-enter in RevenueDot (they cannot be exported) - ... ``` The problem sections are **Store credentials to re-enter**, **Skipped**, **Errors** and **Notes**. Each shows 50 lines; the full list is in the state file. `--json` prints everything. After the first import, enter each app's store credentials in the dashboard (**Apps** → your app) and **run the import again**. With the credentials in place, RevenueDot: - confirms each App Store subscription's `original_transaction_id` with Apple, and - looks up each Google Play subscription's purchase token from its order id. That is how RevenueDot recognises the imported subscription when the store or the app reports on it later, instead of creating a second one. See [Connect the App Store](https://revenuedot.app/docs/guides/app-store.md) and [Connect Google Play](https://revenuedot.app/docs/guides/google-play.md). ## Check the result with import verify ```bash npx revenuedot import verify --rc-key sk_... --rc-project proj... \ --to https://revenuedot.example.com --to-key sk_... ``` For every customer it compares the active entitlements (by identifier), their expiry dates, and how many subscriptions give access. It prints totals for customers, active subscriptions and active entitlements on both sides, then each difference, and exits with `1` when there is one. Purchases made since the last import show up as differences: run the import again, then verify again. `--limit` and `--concurrency` work here too. ## Print the cutover plan ```bash npx revenuedot import plan --to https://revenuedot.example.com --to-key sk_... --rc-project proj... ``` It prints numbered steps for your project: import status, which apps still need store credentials, the notification URL of each app and the forwarding call, the SDK change, the daily re-import, and the final cutover. The same steps are in the [cutover checklist](https://revenuedot.app/docs/migrate/cutover-checklist.md). ## Google Play purchase tokens RevenueCat's [API v2](https://www.revenuecat.com/docs/api-v2) gives Google Play order ids, not purchase tokens, and Google's API needs the token. RevenueDot gets tokens in four ways: 1. **Your service account.** After you add it to the app, the next import looks up tokens by order id with Google's orders API. 2. **A CSV file** you pass with `--google-tokens tokens.csv`, for example an export from RevenueCat support. 3. **Google's next renewal notification** for that subscription, which carries the token. 4. **The app's `syncPurchases()`** call, once after the update. Until a subscription has its token, its key is `needs_token_refresh:` and the customer **keeps access**. A later import with the token upgrades the row in place. The CSV needs a header row. Columns are matched by name, case-insensitive, and separated by commas, semicolons or tabs: | Meaning | Accepted column names | |---|---| | Purchase token (required) | `purchase_token`, `token`, `fetch_token` | | Order id | `order_id`, `orderid`, `store_transaction_id`, `store_subscription_identifier` | | App user id | `app_user_id`, `rc_original_app_user_id`, `customer_id` | | Product id | `product_id`, `product_identifier` | Each row needs the token plus either an order id, or an app user id and a product id. Renewal order ids like `GPA.1234-5678-9012-34567..0` also match their base order id. ```csv purchase_token,order_id abcdefghijk.AO-J1Oz...,GPA.3312-8841-2231-55120 ``` ## The server endpoints it calls The importer writes through three RevenueDot endpoints. They are RevenueDot extensions, not part of RevenueCat's API. You can call them yourself, for example to import from your own database. All take a secret key. **`POST /v2/projects/{project_id}/import/customers`** takes up to 100 customers per call. It needs the `customer_information:customers:read_write` permission. ```bash curl -s -X POST https://revenuedot.example.com/v2/projects/$PROJECT_ID/import/customers \ -H "Authorization: Bearer $SECRET_KEY" -H "Content-Type: application/json" \ -d '{ "customers": [{ "id": "user_123", "aliases": ["$RCAnonymousID:0f3c9a..."], "first_seen_at": 1719830400000, "attributes": [{ "name": "$email", "value": "a@example.com", "updated_at": 1719830400000 }], "subscriptions": [{ "app_id": "app1a2b3c4d", "store": "app_store", "product_identifier": "pro_monthly", "environment": "production", "starts_at": 1719830400000, "current_period_starts_at": 1725105600000, "current_period_ends_at": 1727784000000, "status": "active", "auto_renewal_status": "will_renew", "store_subscription_identifier": "2000000712345678", "original_transaction_id": "2000000612345678" }], "purchases": [{ "app_id": "app1a2b3c4d", "store": "app_store", "product_identifier": "lifetime", "purchased_at": 1719830400000, "store_purchase_identifier": "2000000512345678", "status": "owned" }] }] }' ``` ```json {"object":"import_result","emit_events":false,"customers":[{"id":"user_123","status":"created","subscriptions":1,"purchases":1,"needs_token_refresh":0,"notes":[]}]} ``` - Dates are milliseconds since 1970. - Subscriptions are keyed the way the stores report them: App Store by `original_transaction_id`, Google Play by `purchase_token` (send it when you have it), others by `store_subscription_identifier`. - `emit_events` (default `false`): `true` records lifecycle events and queues webhooks as if the purchases just happened. - `resolve_store_ids` (default `true`): use the app's store credentials, when set, to confirm Apple original transaction ids and look up Google purchase tokens. - Each customer's `status` is `created`, `updated` or `merged`. Customers that share an alias with an existing customer merge into one. - Subscriptions that already ended import as expired, so the expiration job sends nothing for old history. **`POST /v2/projects/{project_id}/import/apps/{app_id}/public_key`** sets an app's public SDK key to the key your shipped builds use. It needs `project_configuration:apps:read_write`. The key must start with the app type's prefix (for example `appl_` for `app_store`, `goog_` for `play_store`), and no other app may use it. ```bash curl -s -X POST https://revenuedot.example.com/v2/projects/$PROJECT_ID/import/apps/$APP_ID/public_key \ -H "Authorization: Bearer $SECRET_KEY" -H "Content-Type: application/json" \ -d '{"public_key":"appl_AbCdEfGhIjKlMnOp"}' ``` ```json {"object":"public_api_key","id":"pk_app1a2b3c4d","key":"appl_AbCdEfGhIjKlMnOp","environment":"production","app_id":"app1a2b3c4d","created_at":1790798214712} ``` **`GET /v2/projects/{project_id}/import/status`** counts what is in the project and how many Google Play subscriptions still wait for a purchase token. It needs `customer_information:customers:read`. ```bash curl -s https://revenuedot.example.com/v2/projects/$PROJECT_ID/import/status -H "Authorization: Bearer $SECRET_KEY" ``` ```json {"object":"import_status","customers":1200,"subscriptions":950,"needs_token_refresh":310,"needs_token_refresh_by_app":{"app9l7z3oij":310}} ``` The full reference is in [REST API extensions](https://revenuedot.app/docs/api/extensions.md). ## Use it from code The package exports `runImport`, `formatReport`, `verifyImport` and `buildPlan`: ```ts import { formatReport, runImport } from "revenuedot"; const report = await runImport({ rcKey: process.env.REVENUECAT_API_KEY!, rcProject: "proj1ab2c3d4", to: "http://localhost:8787", toKey: process.env.REVENUEDOT_API_KEY!, statePath: "./import-state.json", }); console.log(formatReport(report)); ``` ## Related - [Migrate from RevenueCat](https://revenuedot.app/docs/migrate.md) - [Dual run](https://revenuedot.app/docs/migrate/dual-run.md) - [Cutover checklist](https://revenuedot.app/docs/migrate/cutover-checklist.md) - [REST API v2](https://revenuedot.app/docs/api/rest-v2.md) - [Authentication](https://revenuedot.app/docs/api/authentication.md) --- # How do I run RevenueDot and RevenueCat side by side? Source: https://revenuedot.app/docs/migrate/dual-run.md Description: Send store notifications to RevenueDot and let it forward the exact body to RevenueCat, turn on track_new_purchases, keep acting on RevenueCat's webhooks, and compare with revenuedot import verify. Point the stores' server notifications at RevenueDot and set each app's forwarding URL to RevenueCat's notification URL. RevenueDot stores each notification, applies it, and copies the exact body to RevenueCat, so both stay current while old app versions still talk to RevenueCat. Turn on **Track new purchases from server-to-server notifications**, keep acting on RevenueCat's webhooks until cutover, and compare the two systems with `revenuedot import verify`. ## Why a dual run is needed - App Store Connect accepts one production and one sandbox notification URL per app ([Apple's guide](https://developer.apple.com/documentation/appstoreservernotifications/enabling-app-store-server-notifications)), so only one system can receive Apple's notifications directly. - Users on app versions from before your update keep calling RevenueCat. RevenueCat must keep seeing renewals, cancellations and refunds for them. - Users on the new version call RevenueDot. RevenueDot must see the same store events. ## Forward App Store notifications 1. Copy the app's notification URL from RevenueDot: the app's page in the dashboard, or `notification_url` in `GET /v2/projects/{project_id}/apps/{app_id}/store_settings`. It looks like `https://revenuedot.example.com/v1/notifications/apple/{app_id}`. 2. Copy RevenueCat's App Store Server Notification URL from your RevenueCat app settings. 3. Set RevenueDot's forwarding URL to RevenueCat's. In the dashboard, it is the app page field **Forward notifications to RevenueCat or your own server**. With the API: ```bash curl -s -X POST https://revenuedot.example.com/v2/projects/$PROJECT_ID/apps/$APP_ID \ -H "Authorization: Bearer $SECRET_KEY" -H "Content-Type: application/json" \ -d '{"app_store":{"notification_forward_url":"https://"}}' ``` Use `mac_app_store` for a Mac App Store app. Send `null` or `""` to turn forwarding off. 4. In App Store Connect → your app → **App Information** → **App Store Server Notifications**, set both the production and the sandbox URL to RevenueDot's notification URL, version 2. See [Connect the App Store](https://revenuedot.app/docs/guides/app-store.md). ## Forward Google Play notifications Google Play publishes to one Pub/Sub topic ([Google's guide](https://developer.android.com/google/play/billing/getting-ready#configure-rtdn)), and a topic can have many push subscriptions ([Pub/Sub subscriptions](https://cloud.google.com/pubsub/docs/subscriber)). - **If the topic is in your Google Cloud project**, add a second push subscription to RevenueDot's URL, `https://revenuedot.example.com/v1/notifications/google/{app_id}`. RevenueCat's subscription keeps receiving every message, so nothing needs forwarding. - **Otherwise**, point the push subscription at RevenueDot and set the forwarding URL to RevenueCat's Google notification URL, the same way as for Apple, with `play_store`: ```bash -d '{"play_store":{"notification_forward_url":"https://"}}' ``` See [Connect Google Play](https://revenuedot.app/docs/guides/google-play.md). ## How forwarding behaves - RevenueDot stores the raw notification first, then forwards the **exact body** with the same content type. - Forwarding is fire-and-forget with a **10 second timeout**. It never delays the answer to Apple or Google. - RevenueDot records the HTTP status of each forward, or `0` when there was no answer. The latest one is `last_forward` in `store_settings` and on the app page. - A Google message that Pub/Sub redelivers is forwarded only the first time. - Forwarding was verified end to end with a test URL: the body arrived byte for byte and the URL answered 200. It has not yet run against RevenueCat's real notification endpoints. ## Track new purchases from notifications Turn this on for each App Store and Google Play app during the dual run. It is the dashboard checkbox **Track new purchases from server-to-server notifications**, stored as `track_new_purchases`: ```bash curl -s -X POST https://revenuedot.example.com/v2/projects/$PROJECT_ID/apps/$APP_ID \ -H "Authorization: Bearer $SECRET_KEY" -H "Content-Type: application/json" \ -d '{"app_store":{"track_new_purchases":true}}' ``` - **Off (default):** a notification about a purchase RevenueDot has never seen is stored but not applied. - **On:** RevenueDot creates the subscription from the notification. For Apple it uses the transaction's `appAccountToken` as the app user id when there is one; otherwise the owner is a new anonymous customer until the app syncs. Customers you imported are already known, so their notifications apply either way. The setting matters for purchases made on old app versions after your last import. ## Keep your webhooks on RevenueCat until cutover - **Keep acting on RevenueCat's webhooks.** If RevenueDot sent webhooks to the same handler, your backend would process every purchase twice. - To compare, create a RevenueDot webhook that points at a separate endpoint which **only logs**. RevenueDot uses RevenueCat's payload shape, `{ "api_version": "1.0", "event": { ... } }`, so the same parser works. Deduplicate on `event.id`. - Imported history sends no webhooks, so the comparison endpoint sees only new events. - Differences to expect are listed in [What differs from RevenueCat](https://revenuedot.app/docs/migrate/what-differs.md#webhooks). Webhook setup, signature checks and retries: [Webhooks](https://revenuedot.app/docs/guides/webhooks.md). ## Re-import and compare Re-run the import while old app versions still call RevenueCat. It is idempotent, so a daily run is safe: ```bash npx revenuedot import --from-revenuecat --rc-key sk_... --rc-project proj... \ --to https://revenuedot.example.com --to-key sk_... npx revenuedot import verify --rc-key sk_... --rc-project proj... \ --to https://revenuedot.example.com --to-key sk_... ``` `import verify` compares each customer's active entitlements, their expiry dates and how many subscriptions give access. It exits with `1` when anything differs. A difference that remains after a fresh import points to data the import could not bring over; the details are in [The importer](https://revenuedot.app/docs/migrate/importer.md#check-the-result-with-import-verify). ## Related - [Migrate from RevenueCat](https://revenuedot.app/docs/migrate.md) - [Cutover checklist](https://revenuedot.app/docs/migrate/cutover-checklist.md) - [Store notifications not arriving](https://revenuedot.app/docs/help/store-notifications-not-arriving.md) - [Webhooks](https://revenuedot.app/docs/guides/webhooks.md) --- # What do I change in my app's SDK code to move to RevenueDot? Source: https://revenuedot.app/docs/migrate/sdk-changes.md Description: In proxy mode, set the proxy URL before configure, turn off signature checks and sync purchases once. With a fork, swap the package and keep your code. Diffs for all ten SDKs. In **proxy mode** you add a few lines: set the proxy URL before `configure`, turn off signature checks, and call `syncPurchases()` once after the update. With a **fork**, you swap the package in your manifest and keep every line of code. Proxy mode works today; the forks are not published yet (2026-09-30). ## Proxy mode or fork swap | | Proxy mode | Fork swap | |---|---|---| | Works today | Yes | No: not on any registry yet | | Code change | 2 to 5 lines at startup | None; the manifest changes | | Signature checks | Must be off (or informational); they would fail | Verify against RevenueDot Cloud's key; self-hosters use their own build or keep them off | | Android diagnostics, paywall and ad events | Still go to RevenueCat | Go to your server | | Web analytics events | Turn off, or they go to RevenueCat | Go to your server | | Flutter web | Does not work | Works | | Proxy URL | Required: `https://api.revenuedot.app` for RevenueDot Cloud, or your own server | Not needed for RevenueDot Cloud (the default host); required when you self-host | **Keys:** if you ran the [importer](https://revenuedot.app/docs/migrate/importer.md), each app keeps its RevenueCat public key, so the `apiKey` lines below stay as they are. Otherwise use the keys RevenueDot shows for each app. **Sync once:** `syncPurchases()` sends the device's store purchases to RevenueDot, so current subscribers keep access even where their history was not imported. On Google Play it also delivers any purchase token the importer could not find. Call it once, on the first launch after the update. ## iOS ```diff import RevenueCat +// Point the SDK at your RevenueDot server; nothing else in the app changes. Set it before configure. +Purchases.proxyURL = URL(string: "https://revenuedot.example.com")! -Purchases.configure(withAPIKey: "appl_...") +Purchases.configure( + with: Configuration.Builder(withAPIKey: "appl_...") + // The default (informational) logs every RevenueDot response as failed signature verification. + .with(entitlementVerificationMode: .disabled) + .build() +) +// Once, after this update: send purchases made while the app talked to RevenueCat. +_ = try? await Purchases.shared.syncPurchases() ``` Fork swap (planned): `pod 'RevenueCat'` becomes `pod 'RevenueDotPurchases'`, or the SPM URL becomes `https://github.com/revenuedot/purchases-ios`. `import RevenueCat` stays. Guide: [iOS](https://revenuedot.app/docs/sdks/ios.md). ## Android ```diff override fun onCreate() { super.onCreate() + // Point the SDK at your RevenueDot server; nothing else in the app changes. Must be set before configure. + Purchases.proxyURL = URL("https://revenuedot.example.com") Purchases.configure( PurchasesConfiguration.Builder(this, "goog_...") + // The default (INFORMATIONAL) logs every RevenueDot response as failed signature verification. + .entitlementVerificationMode(EntitlementVerificationMode.DISABLED) .build() ) + // Once, after this update: send purchases made while the app talked to RevenueCat. + Purchases.sharedInstance.syncPurchases() } ``` Fork swap (planned): ```diff -implementation("com.revenuecat.purchases:purchases:") +implementation("app.revenuedot.purchases:purchases:") ``` Guide: [Android](https://revenuedot.app/docs/sdks/android.md). ## React Native and Expo ```diff import Purchases from "react-native-purchases"; +// Point the SDK at your RevenueDot server; nothing else in the app changes. Await it before configure. +await Purchases.setProxyURL("https://revenuedot.example.com"); Purchases.configure({ apiKey: Platform.OS === "ios" ? "appl_..." : "goog_...", - entitlementVerificationMode: ENTITLEMENT_VERIFICATION_MODE.INFORMATIONAL, + // DISABLED is the React Native default; RevenueDot does not sign responses with RevenueCat's key. }); +// Once, after this update: send purchases made while the app talked to RevenueCat. +await Purchases.syncPurchasesForResult(); ``` Fork swap (planned), in `package.json`: ```diff -"react-native-purchases": "", +"react-native-purchases": "npm:@revenuedot/react-native-purchases@", ``` Guide: [React Native](https://revenuedot.app/docs/sdks/react-native.md). ## Flutter ```diff import 'package:purchases_flutter/purchases_flutter.dart'; Future initPurchases() async { + // Point the SDK at your RevenueDot server; nothing else in the app changes. Await it before configure. + await Purchases.setProxyURL('https://revenuedot.example.com'); await Purchases.configure(PurchasesConfiguration(Platform.isIOS ? 'appl_...' : 'goog_...')); + // Once, after this update: send purchases made while the app talked to RevenueCat. + await Purchases.syncPurchases(); } ``` Flutter web cannot use a proxy URL with the stock package. Fork swap (planned), in `pubspec.yaml`: ```diff - purchases_flutter: ^ + purchases_flutter: + git: + url: https://github.com/revenuedot/purchases-flutter.git + ref: -revenuedot ``` Guide: [Flutter](https://revenuedot.app/docs/sdks/flutter.md). ## Web (purchases-js) ```diff const purchases = Purchases.configure({ apiKey: "test_...", appUserId, + // Point the SDK at your RevenueDot server; nothing else in the app changes. No trailing slash. + httpConfig: { proxyURL: "https://revenuedot.example.com" }, + // Analytics events do not use the proxy URL; turn them off to keep all traffic on your server. + flags: { collectAnalyticsEvents: false }, }); ``` Only Test Store (`test_`) keys work against RevenueDot today; Web Billing (`rcb_`), Stripe and Paddle do not. Fork swap (planned): ```diff -"@revenuecat/purchases-js": "", +"@revenuecat/purchases-js": "npm:@revenuedot/purchases-js@", ``` Guide: [Web](https://revenuedot.app/docs/sdks/web.md). ## Capacitor and Ionic ```diff -import { Purchases } from "@revenuecat/purchases-capacitor"; +import { ENTITLEMENT_VERIFICATION_MODE, Purchases } from "@revenuecat/purchases-capacitor"; +// Point the SDK at your RevenueDot server; nothing else in the app changes. Await it before configure. +await Purchases.setProxyURL({ url: "https://revenuedot.example.com" }); await Purchases.configure({ apiKey: isIOS ? "appl_..." : "goog_...", + // Capacitor passes no default, so the native default (informational signature checks) would apply. + entitlementVerificationMode: ENTITLEMENT_VERIFICATION_MODE.DISABLED, }); +// Once, after this update: send purchases made while the app talked to RevenueCat. +await Purchases.syncPurchases(); ``` Fork swap (planned), only through the alias so Capacitor's native names stay: ```diff -"@revenuecat/purchases-capacitor": "", +"@revenuecat/purchases-capacitor": "npm:@revenuedot/purchases-capacitor@", ``` Guide: [Capacitor](https://revenuedot.app/docs/sdks/capacitor.md). ## Kotlin Multiplatform ```diff fun initPurchases(apiKey: String) { + // Point the SDK at your RevenueDot server; nothing else in the app changes. A String, set before configure. + Purchases.proxyURL = "https://revenuedot.example.com" Purchases.configure(PurchasesConfiguration(apiKey) { + // DISABLED is the KMP default; keep it, RevenueDot does not sign responses with RevenueCat's key. + verificationMode = EntitlementVerificationMode.DISABLED }) } ``` Then call `Purchases.sharedInstance.awaitSyncPurchases()` once from a coroutine. Fork swap (planned): ```diff -implementation("com.revenuecat.purchases:purchases-kmp-core:") +implementation("app.revenuedot.purchases:purchases-kmp-core:") ``` Guide: [Kotlin Multiplatform](https://revenuedot.app/docs/sdks/kotlin-multiplatform.md). ## Unity In the Inspector, on the GameObject that holds the **Purchases** component: | Field | Before | After | |---|---|---| | Proxy URL | (empty) | `https://revenuedot.example.com` | | Entitlement Verification Mode | Informational | Disabled | There is no public `SetProxyURL` method; the field applies also when you configure from code: ```diff var purchases = GetComponent(); -purchases.Configure(Purchases.PurchasesConfiguration.Builder.Init("appl_...").Build()); +purchases.Configure(Purchases.PurchasesConfiguration.Builder.Init("appl_...") + .SetEntitlementVerificationMode(Purchases.EntitlementVerificationMode.Disabled) + .Build()); +// Once, after this update: send purchases made while the app talked to RevenueCat. +purchases.SyncPurchases(); ``` Fork swap (planned): OpenUPM package `com.revenuecat.purchases-unity` becomes `com.revenuedot.purchases-unity`. Guide: [Unity](https://revenuedot.app/docs/sdks/unity.md). ## Cordova ```diff document.addEventListener("deviceready", () => { + // Point the SDK at your RevenueDot server; nothing else in the app changes. Call it before configure. + Purchases.setProxyURL("https://revenuedot.example.com"); Purchases.configureWith({ apiKey: device.platform === "iOS" ? "appl_..." : "goog_..." }); + // Once, after this update: send purchases made while the app talked to RevenueCat. + Purchases.syncPurchases(); }); ``` Cordova has no option to turn off signature checks, so the SDK logs a verification failure for every RevenueDot response and still grants access. Fork swap (planned): `cordova plugin add @revenuedot/cordova-plugin-purchases`; the plugin id stays `cordova-plugin-purchases`. Guide: [Cordova](https://revenuedot.app/docs/sdks/cordova.md). ## purchases-hybrid-common No change. Apps never depend on it directly: the React Native, Flutter, Capacitor, Unity and Cordova packages bring it in, and their fork packages bring in RevenueDot's build. See [Hybrid common](https://revenuedot.app/docs/sdks/hybrid-common.md). ## Related - [All SDKs](https://revenuedot.app/docs/sdks.md) - [Trusted Entitlements](https://revenuedot.app/docs/guides/trusted-entitlements.md) - [Cutover checklist](https://revenuedot.app/docs/migrate/cutover-checklist.md) - [What differs from RevenueCat](https://revenuedot.app/docs/migrate/what-differs.md) --- # What is the checklist for cutting over from RevenueCat to RevenueDot? Source: https://revenuedot.app/docs/migrate/cutover-checklist.md Description: The migration steps in order, from starting the server to turning RevenueCat off, as a checklist. Each step says how to check it is done. Work through these steps in order. RevenueCat keeps running until the last section, so any step can be paused or undone. `revenuedot import plan` prints the same steps with your app ids and URLs filled in. ## 1. Set up RevenueDot - [ ] Run a RevenueDot server that the internet can reach over HTTPS. See [Self-hosting](https://revenuedot.app/docs/guides/self-hosting.md) and [Going to production](https://revenuedot.app/docs/guides/going-to-production.md). Check: `GET https://revenuedot.example.com/v1/health` returns `{"status":"ok"}`. - [ ] Create a project and a secret key (`sk_...`) on the dashboard's **API keys** page. - [ ] Create a RevenueCat v2 secret key with read access to project configuration and customer information. - [ ] Optional: set `REVENUEDOT_SIGNING_KEY` if you plan to use fork builds with your own key. See [Trusted Entitlements](https://revenuedot.app/docs/guides/trusted-entitlements.md). ## 2. Import - [ ] Dry run: `npx revenuedot import --from-revenuecat --rc-key sk_... --rc-project proj... --to https://revenuedot.example.com --to-key sk_... --dry-run`. Until the CLI is on npm, run it from source; see [The importer](https://revenuedot.app/docs/migrate/importer.md#run-it). - [ ] Import: the same command without `--dry-run`. Re-run it until the report says the pass is complete. - [ ] Add each app's store credentials in the dashboard: the App Store in-app purchase key, and the Google Play service account with "View financial data". See [Connect the App Store](https://revenuedot.app/docs/guides/app-store.md) and [Connect Google Play](https://revenuedot.app/docs/guides/google-play.md). - [ ] Click **Check credentials** on each app page, so Apple and Google confirm the credentials work. - [ ] Run the import again, so Apple transaction ids are confirmed and Google purchase tokens are looked up. - [ ] Optional: pass `--google-tokens tokens.csv` if you have a token export. - [ ] Check `GET /v2/projects/{project_id}/import/status`: `needs_token_refresh` should be falling. The rest arrive with renewals and `syncPurchases()`. - [ ] Run `npx revenuedot import verify ...`. Fix or explain every difference. ## 3. Run side by side - [ ] For each App Store app, set RevenueDot's forwarding URL to RevenueCat's App Store notification URL. See [Dual run](https://revenuedot.app/docs/migrate/dual-run.md#forward-app-store-notifications). - [ ] In App Store Connect, set the production and sandbox Server Notification URLs (version 2) to RevenueDot's `notification_url`. - [ ] For each Google Play app, add a second Pub/Sub push subscription to RevenueDot's URL, or forward like Apple. See [Dual run](https://revenuedot.app/docs/migrate/dual-run.md#forward-google-play-notifications). - [ ] Turn on **Track new purchases from server-to-server notifications** for each store app. - [ ] Check the app page: notifications show a recent "last received" time, and the last forward shows status 200. - [ ] Keep acting on RevenueCat's webhooks. If you add a RevenueDot webhook now, point it at an endpoint that only logs. ## 4. Ship the app update - [ ] Set the proxy URL before `configure`, and turn signature checks off. Per SDK: [SDK changes](https://revenuedot.app/docs/migrate/sdk-changes.md). - [ ] Call `syncPurchases()` once on the first launch after the update. - [ ] Test the build against RevenueDot with the [Test Store](https://revenuedot.app/docs/guides/test-store.md) and the stores' sandboxes. See [Sandbox testing](https://revenuedot.app/docs/guides/sandbox-testing.md). - [ ] Release the update. - [ ] Watch the SDK compatibility panel on the **Apps** page and the customer timeline for purchases from the new version. ## 5. Keep both in step - [ ] Re-run the import daily while old app versions still call RevenueCat. - [ ] Run `import verify` after each re-import. - [ ] Optional: raise the minimum app version, so old versions stop calling RevenueCat sooner. ## 6. Cut over - [ ] `import verify` shows no differences, and few active users run an old version. - [ ] Create your webhooks in RevenueDot. Save each signing secret (`whsec_...`) and verify signatures. See [Webhooks](https://revenuedot.app/docs/guides/webhooks.md). - [ ] Turn off the webhooks in RevenueCat, in the same hour. - [ ] Remove each forwarding URL: send `"notification_forward_url": null`. - [ ] Move dashboards, alerts and internal tools to RevenueDot's [REST API v2](https://revenuedot.app/docs/api/rest-v2.md); it uses the same shapes. - [ ] Turn RevenueCat off for this project. ## Related - [Migrate from RevenueCat](https://revenuedot.app/docs/migrate.md) - [The importer](https://revenuedot.app/docs/migrate/importer.md) - [Dual run](https://revenuedot.app/docs/migrate/dual-run.md) - [What differs from RevenueCat](https://revenuedot.app/docs/migrate/what-differs.md) --- # What differs between RevenueDot and RevenueCat? Source: https://revenuedot.app/docs/migrate/what-differs.md Description: Every known difference as of 2026-09-30, from failed signature checks with the stock SDK to webhook events not sent yet and features planned for later tiers, with sources. RevenueDot answers the RevenueCat SDKs, REST API v1 and v2, and webhook payloads in RevenueCat's shapes, so purchases, entitlements, offerings and customer info work the same. The differences are: stock SDKs report failed signature checks, a few SDK features answer empty because they are not built yet, some webhook event types are never sent, and paywalls, experiments, charts and several stores are planned for later tiers. This page lists every known difference as of 2026-09-30. ## SDK behaviour **Signature checks (Trusted Entitlements)** - The stock SDKs verify responses with RevenueCat's signing key, which RevenueDot cannot use. Every RevenueDot response reads as verification failed. - iOS, Android and Unity default to informational: they log the failure and still grant access. Set disabled. React Native, Flutter and Kotlin Multiplatform default to disabled. Capacitor passes no default, so the native informational default applies. Cordova has no option. purchases-js does not verify. - **Enforced mode fails every request** with a stock SDK against RevenueDot. - RevenueDot signs responses with its own Ed25519 key when `REVENUEDOT_SIGNING_KEY` is set, and the RevenueDot forks trust RevenueDot Cloud's key. See [Trusted Entitlements](https://revenuedot.app/docs/guides/trusted-entitlements.md). - Source: [`prd/sdk-forks/PRD.md`](https://github.com/revenuedot/revenuedot/blob/main/prd/sdk-forks/PRD.md), section 4. **Traffic that bypasses the proxy URL in stock SDKs** - Android sends diagnostics, paywall events and ad events to RevenueCat's hosts even with a proxy URL. - purchases-js sends analytics events to RevenueCat unless you set `flags: { collectAnalyticsEvents: false }`. - Flutter web ignores `setProxyURL`, so web calls still go to RevenueCat. - The RevenueDot forks fix all three. Source: [`prd/sdk-forks/PRD.md`](https://github.com/revenuedot/revenuedot/blob/main/prd/sdk-forks/PRD.md), section 2. **SDK features that answer empty today** | SDK feature | What RevenueDot answers | Effect in the app | |---|---|---| | Customer Center | `GET /v1/customercenter/{id}` answers 404 with code 7259; support tickets answer `{"sent": false}` | The Customer Center screen shows its error state | | Virtual currencies | `{"virtual_currencies": {}}` | Balances are always empty | | Paywalls built in RevenueCat's editor | Offerings carry no paywall | Remote paywall designs do not appear | | Targeting and placements | Offerings carry no placements | Every user gets the current offering | | Remote config (`/v1/config/...`) | 204, no body | Nothing to apply | | Analytics and diagnostics (`/v1/events`, `/v1/diagnostics`) | Accepted, then dropped | No effect | | Web purchase redemption (`redeemWebPurchase`) | 400 with code 7849 | The SDK returns `invalidToken`: there are no web purchases to redeem | | Web checkout (iOS hosted checkout, purchases-js with `rcb_` keys) | 400 with code 7000; branding answers the app's name | The checkout fails with an error and is not retried | | Rewarded ad verification (`pollRewardVerification`) | `status: failed` | Polling stops after one request and returns failed | | Amazon receipt lookup (Android) | 400 with code 7662 | The Amazon purchase fails and stays unconsumed | | SDK health report | Always "passed" | No effect | Source: [`apps/server/src/routes/sdk.ts`](https://github.com/revenuedot/revenuedot/blob/main/apps/server/src/routes/sdk.ts). **Attribution and promotional offers work** - `setAttributes` and the reserved setters, `collectDeviceIdentifiers` (RevenueDot fills in `$ip` and `$deviceVersion`), the deprecated Apple Search Ads `addAttributionData` and `enableAdServicesAttributionTokenCollection` are stored as the reserved attributes, such as `$mediaSource`, `$campaign` and the `$appleAds*` ids. RevenueDot looks the AdServices token up with Apple's attribution API. Apple returns campaign ids, not names. - Promotional offers (`POST /v1/offers`) are signed with the App Store app's In-App Purchase key. Without the key the SDK gets code 7234 for that offer. - The full list of SDK calls and what each answers is in [`prd/sdk-api/PRD.md`](https://github.com/revenuedot/revenuedot/blob/main/prd/sdk-api/PRD.md#endpoint-inventory). **Stores and receipts** - RevenueDot accepts purchases only for App Store, Mac App Store, Google Play and Test Store apps. Receipts for Amazon, Web Billing (`rcb_`), Stripe, Paddle and Roku apps answer HTTP 400 with code 7662. - purchases-js therefore works only with Test Store (`test_`) keys. - StoreKit 1 receipts need the App Store in-app purchase key on the app. Without it RevenueDot answers code 7234 as HTTP 500, so the SDK retries after you add the key. For development, `allow_unsigned_receipts` skips this. - The app-specific shared secret is stored but not used. - Temporary failures, RevenueDot's or a store's, answer 5xx so the SDK retries. A 4xx means the purchase is permanently bad. See [Receipt errors: 4xx vs 5xx](https://revenuedot.app/docs/help/receipt-errors-4xx-vs-5xx.md). **Test Store** - Test Store prices are set on the product with `test_store_price` (`amount_micros`, `currency`) on product create and update, not with RevenueCat's separate `test_store_prices` endpoint. Read them with `expand=indicative_price`. - Servers older than the 2026-09-30 fix send `cycle_count: null` in `GET /rcbilling/v1/subscribers/{id}/products`, and the native iOS SDK then reports "No base price found for product". Update the server if you see it. - Test Store purchases are always sandbox purchases. See [Test Store](https://revenuedot.app/docs/guides/test-store.md). **Other SDK details** - Unity has no public `SetProxyURL` method; use the **Proxy URL** field on the Purchases component. See [Unity](https://revenuedot.app/docs/sdks/unity.md). - Restores follow the project's `transfer_behavior`: `transfer` (default), `transfer_if_no_active`, `keep` or `share`. With `keep`, a restore of a receipt another user owns fails with code 7102. See [Customers and app user IDs](https://revenuedot.app/docs/concepts/customers-and-app-user-ids.md#who-owns-a-restored-purchase). ## API - **Keys:** the same prefixes as RevenueCat. Public app keys are `appl_`, `mac_`, `goog_`, `test_`, `amzn_`, `strp_`, `rcb_`, `pdl_` and `roku_`; secret keys are `sk_` and belong to one project. See [Projects, apps and API keys](https://revenuedot.app/docs/concepts/projects-and-apps.md). - **REST API v1:** all 15 endpoints, with RevenueCat's shapes. See [REST API v1](https://revenuedot.app/docs/api/rest-v1.md). - **REST API v2:** projects, apps, products, entitlements, offerings, packages, customers, subscriptions, purchases and webhook integrations, with RevenueCat's shapes, pagination and errors. The rest of RevenueCat's v2 API, the audit log and team roles are planned for Tier 2. See [REST API v2](https://revenuedot.app/docs/api/rest-v2.md). - **Extensions that RevenueCat's API does not have:** the import endpoints, `notification_forward_url` on apps, `POST /v2/projects/{project_id}/test_purchases`, store settings and credential checks, mass subscription extension, and webhook delivery logs with retry. See [REST API extensions](https://revenuedot.app/docs/api/extensions.md). - **Dashboard sessions** can call `/v2` for every project you belong to; a secret key covers one project. - **The importer** is RevenueDot's own; see [The importer](https://revenuedot.app/docs/migrate/importer.md). ## Webhooks - **Payload:** RevenueCat's shape, `{ "api_version": "1.0", "event": { ... } }`, sent as a `POST` with JSON. - **Headers:** an optional `Authorization` header you configure, plus `X-RevenueCat-Webhook-Signature: t=,v1=` signed with your `whsec_...` secret, and `User-Agent: RevenueDot-Webhooks/1.0`. - **Delivery:** only HTTP 200 counts as delivered. Failed deliveries retry after 5, 10, 20, 40 and 80 minutes, then stop. Each attempt times out after 60 seconds. You can retry by hand in the dashboard or the API. - **Event types sent today:** `INITIAL_PURCHASE`, `RENEWAL`, `CANCELLATION`, `UNCANCELLATION`, `NON_RENEWING_PURCHASE`, `SUBSCRIPTION_PAUSED`, `EXPIRATION`, `BILLING_ISSUE`, `PRODUCT_CHANGE`, `SUBSCRIPTION_EXTENDED`, `REFUND_REVERSED`, `TRANSFER`, `PRICE_INCREASE_CONSENT_REQUIRED`, `PRICE_INCREASE_CONSENT_APPROVED` and `TEST`. - **Accepted in filters but never sent yet:** `TEMPORARY_ENTITLEMENT_GRANT`, `VIRTUAL_CURRENCY_TRANSACTION`, `INVOICE_ISSUANCE`, `EXPERIMENT_ENROLLMENT`, `PURCHASE_REDEEMED` and `SUBSCRIBER_ALIAS`. - **Refunds** arrive as `CANCELLATION` with `cancel_reason: "CUSTOMER_SUPPORT"` and a negative price. - **Imported history sends no webhooks**, unless you import with `--emit-events`. - **Integrations:** webhooks are the only integration today. Slack, Segment, Amplitude, Mixpanel, PostHog, Firebase, BigQuery, AppsFlyer, Adjust and Meta are planned for Tier 2. Details: [Webhooks](https://revenuedot.app/docs/guides/webhooks.md) and [Webhook events](https://revenuedot.app/docs/api/webhook-events.md). Source: [`apps/server/src/services/webhooks.ts`](https://github.com/revenuedot/revenuedot/blob/main/apps/server/src/services/webhooks.ts). ## Migration - **Not imported:** paywalls, targeting rules, experiments, virtual currency balances, integrations other than webhooks, and store credentials. - **Refunded subscriptions import as expired**, because RevenueCat's [API v2](https://www.revenuecat.com/docs/api-v2) subscription object does not show refunds. - **RevenueCat Billing subscriptions** keep renewing through RevenueCat; only their current access is imported. - **Google Play purchase tokens** are not in RevenueCat's [API v2](https://www.revenuecat.com/docs/api-v2), which gives order ids. Until RevenueDot finds a token, the subscription is marked `needs_token_refresh` and the customer keeps access. Source: [`prd/migration/PRD.md`](https://github.com/revenuedot/revenuedot/blob/main/prd/migration/PRD.md). ## Features not built yet, by tier **Tier 1 (the current build), not finished** - SDK fork packages are built but not published to any registry. - Real App Store and Google Play sandbox purchases have not run end to end; store handling is tested against mocked Apple and Google APIs. **Tier 2 (planned)** - The full REST API v2, the audit log and team roles. - All 21 webhook event types and the integrations listed above; scheduled data exports to S3, R2 or GCS. - All 42 charts, with the SQL published. - Paywalls: serving the paywall designs the SDKs render, a visual editor and an asset CDN. - Targeting, placements and experiments (offering A/B tests). - Customer Center configuration, virtual currencies, offline entitlements, promotional-offer signing and win-back offers. - Amazon Appstore, and Stripe subscriptions from your own Stripe account. - Moving between self-host and cloud in one step, and a full export. **Tier 3 (planned)** - SSO/SAML, SCIM, custom roles, several organizations, data-location controls and compliance exports. - High-availability self-host (Helm, Terraform, clustering). - Web billing with hosted checkout, web-to-app funnels and redemption links. - Failed-payment recovery, refund defense and win-back flows. - Paddle, Roku and Galaxy stores; forwarding attribution to ad networks; benchmarks. Source: [`prd/SCOPE.md`](https://github.com/revenuedot/revenuedot/blob/main/prd/SCOPE.md). ## Related - [Migrate from RevenueCat](https://revenuedot.app/docs/migrate.md) - [SDK changes](https://revenuedot.app/docs/migrate/sdk-changes.md) - [Known issues](https://revenuedot.app/docs/help/known-issues.md) - [All SDKs](https://revenuedot.app/docs/sdks.md) === API reference === # What APIs does RevenueDot have? Source: https://revenuedot.app/docs/api.md Description: RevenueDot serves the SDK endpoints, REST API v1, REST API v2 with extensions, store notification endpoints and webhooks, all described in one OpenAPI 3.1 document. One RevenueDot server answers every API below on one port. The reference is generated from [openapi.yaml](https://revenuedot.app/docs/api/openapi.yaml) (OpenAPI 3.1), which a script checks against the server's route files on every change, so a path in these pages exists in the code. | API | Paths | Auth | Operations | Reference | |---|---|---|---|---| | SDK endpoints and store notifications | `/v1/...`, `/rcbilling/...` | public app key | 44 | [SDK endpoints](https://revenuedot.app/docs/api/sdk-endpoints.md) | | REST API v1 | `/v1/subscribers/...` | secret key | 10 | [REST API v1](https://revenuedot.app/docs/api/rest-v1.md) | | REST API v2 | `/v2/projects/...` | secret key or dashboard session | 73 | [REST API v2](https://revenuedot.app/docs/api/rest-v2.md) | | RevenueDot extensions | `/v2/...`, `/auth/...`, `/oauth/...` | secret key, session or none | 47 | [Extensions](https://revenuedot.app/docs/api/extensions.md) | | Webhooks (sent by RevenueDot) | your URL | HMAC signature | 15 event types | [Webhook events](https://revenuedot.app/docs/api/webhook-events.md) | ## Quick example ```bash export REVENUEDOT_URL=http://localhost:8787 SECRET_KEY=sk_... PROJECT_ID=proj... curl -s "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/customers/user_1" -H "Authorization: Bearer $SECRET_KEY" ``` ```json { "object": "customer", "id": "user_1", "project_id": "proj18pzzkao", "first_seen_at": 1790800914012, "last_seen_at": 1790800914034, "last_seen_app_version": null, "last_seen_country": null, "last_seen_platform": null, "last_seen_platform_version": null, "active_entitlements": { "object": "list", "items": [ { "object": "customer.active_entitlement", "entitlement_id": "entl1v0bp6r0qs", "expires_at": 1793392914000 } ], "next_page": null, "url": "/v2/projects/proj18pzzkao/customers/user_1/active_entitlements" }, "experiment": null } ``` ## Conventions - **Compatible first.** Paths, field names, list envelopes and error formats follow RevenueCat's public API. Operations that exist only in RevenueDot are marked "RevenueDot extension" and carry `x-revenuedot-extension: true` in the OpenAPI document. - **Timestamps.** REST v2 and webhooks use epoch milliseconds. SDK customer info uses ISO 8601 strings in UTC with whole seconds. - **IDs.** Customers are addressed by any of their app user ids. Other objects have prefixed ids: `proj`, `app`, `prod`, `entl`, `ofrng`, `pkge`, `sub_`, `wh_`, `key_`. - **Pagination.** `limit` (1 to 100, default 20) and `starting_after`; follow `next_page`. ## Use the OpenAPI document - Import [openapi.yaml](https://revenuedot.app/docs/api/openapi.yaml) into Postman, Insomnia or Bruno, or generate a client with any OpenAPI 3.1 generator. - Each operation's `x-source` names the server file that implements it, and `x-scopes` lists the permissions it needs. ## Related - [Authentication](https://revenuedot.app/docs/api/authentication.md) - [Errors](https://revenuedot.app/docs/api/errors.md) - [Quickstart](https://revenuedot.app/docs/getting-started/quickstart.md) --- # How do I authenticate RevenueDot API requests? Source: https://revenuedot.app/docs/api/authentication.md Description: RevenueDot uses public app keys for the SDK endpoints, secret keys for REST API v1 and v2, and a session cookie for the dashboard. Secret keys carry permissions. Send every key as a bearer token: `Authorization: Bearer `. Which key depends on the API: | Credential | Looks like | Use it for | Keep it | |---|---|---|---| | Public app key | `appl_...`, `goog_...`, `test_...` | [SDK endpoints](https://revenuedot.app/docs/api/sdk-endpoints.md) for that app | In your app. It is public by design | | Secret key | `sk_...` | [REST API v1](https://revenuedot.app/docs/api/rest-v1.md), [REST API v2](https://revenuedot.app/docs/api/rest-v2.md), [extensions](https://revenuedot.app/docs/api/extensions.md), and SDK endpoints from your backend | On your servers only | | Dashboard session | cookie `rd_session` | REST API v2 from the dashboard, for every project you belong to | In the browser | | Pub/Sub push token | Google-signed JWT | Google Play notifications, when `pubsub_audience` is set | Sent by Google | ## Security schemes in the OpenAPI document - **`publicApiKey`**: A public app key (`appl_`, `mac_`, `goog_`, `test_`, `amzn_`, `strp_`, `rcb_`, `pdl_`, `roku_`). Safe to ship in an app. The SDK sends it on every request. - **`secretApiKey`**: A project secret key (`sk_...`). Server side only. Its `permissions` limit what it can do. - **`dashboardSession`**: The dashboard session cookie from `POST /auth/login`. It authorizes `/v2` for every project the user belongs to. - **`googlePubSubOidc`**: Google-signed OIDC token of a Pub/Sub push subscription. Checked only when the app's `pubsub_audience` credential is set. ## Where keys come from - **Public app key:** created with the app. Read it with `GET /v2/projects/{project_id}/apps/{app_id}/public_api_keys` or on the app's dashboard page. During a migration you can keep your RevenueCat key with `POST /v2/projects/{project_id}/import/apps/{app_id}/public_key`. - **Secret key:** `POST /v2/projects/{project_id}/api_keys` or the dashboard's **API keys** page. The key is shown once. RevenueDot stores only its SHA-256 hash. - **OAuth for MCP clients:** an MCP client can get a secret key through [OAuth 2.1 with PKCE](https://revenuedot.app/docs/api/extensions.md#oauth-for-mcp-clients). The user picks one project and read or read-write access. ## Permissions (secret keys) A secret key belongs to one project and holds a list of permissions. `*` allows everything. A `read_write` permission also allows `read`. A prefix wildcard such as `customer_information:*` allows every permission under it. Dashboard users with the viewer role get only `read` permissions. A key can only create keys with permissions it holds itself. Permissions the operations use: - `charts_metrics:overview:read` - `customer_information:customers:read` - `customer_information:customers:read_write` - `customer_information:purchases:read` - `customer_information:purchases:read_write` - `customer_information:subscriptions:read` - `customer_information:subscriptions:read_write` - `project_configuration:api_keys:read` - `project_configuration:api_keys:read_write` - `project_configuration:apps:read` - `project_configuration:apps:read_write` - `project_configuration:collaborators:read` - `project_configuration:entitlements:read` - `project_configuration:entitlements:read_write` - `project_configuration:integrations:read` - `project_configuration:integrations:read_write` - `project_configuration:offerings:read` - `project_configuration:offerings:read_write` - `project_configuration:packages:read` - `project_configuration:packages:read_write` - `project_configuration:products:read` - `project_configuration:products:read_write` - `project_configuration:projects:read` - `project_configuration:projects:read_write` Each operation's section on the reference pages lists the permissions it needs. A missing permission answers 403 `authorization_error` and names it. ## Common mistakes - **A public key on REST API v2** answers 403: "API v2 requires a secret API key (sk_...)". - **A secret key in an app** gives anyone who unpacks the app full access to your project. Delete it (`DELETE /v2/projects/{project_id}/api_keys/{key_id}`) and create a new one. - **Another project's id** with a valid key answers 404, not 403, so ids cannot be probed. ## Related - [Projects, apps and API keys](https://revenuedot.app/docs/concepts/projects-and-apps.md) - [Errors](https://revenuedot.app/docs/api/errors.md) --- # What do RevenueDot's API errors mean? Source: https://revenuedot.app/docs/api/errors.md Description: The SDK and REST v1 error codes, the REST v2 error types, which HTTP status each uses, and what to do about each one. RevenueDot answers errors in the format each API's clients already parse: SDK endpoints and REST v1 send `{ "code", "message" }` with RevenueCat's backend codes, and REST v2 sends an `error` object with a `type`. For receipts, the HTTP status class matters most: a 4xx makes the SDK finish the transaction for good, and a 5xx makes it retry. See [4xx or 5xx on a receipt](https://revenuedot.app/docs/help/receipt-errors-4xx-vs-5xx.md). ## SDK and REST v1 error codes ```json { "code": 7103, "message": "The receipt is not a valid Test Store purchase token." } ``` | Code | Name | HTTP status | Meaning | |---|---|---|---| | 7000 | BAD_REQUEST / INVALID_PLATFORM | 400 | The request is malformed, a secret-key receipt post has no X-Platform app, the store action does not exist for this store, or a web checkout was asked for (RevenueDot takes no web payments). | | 7101 | STORE_PROBLEM | 400 or 503 | The store refused the request (400), or the store or its credentials could not be used right now (503, retry later). | | 7102 | RECEIPT_ALREADY_IN_USE | 400 | The purchase belongs to another customer and the project's transfer behaviour is keep or transfer_if_no_active. | | 7103 | INVALID_RECEIPT | 400 | The receipt, signed transaction or purchase token is not valid, or it belongs to another bundle id or package name. | | 7110 | INTERNAL | 500 | An unexpected server error. The SDK keeps the transaction and retries. | | 7220 | INVALID_APP_USER_ID | 400 | The app user id is empty or longer than 100 characters. | | 7224 | INVALID_AUTH_TOKEN | 401 | A Google Pub/Sub push token is missing or invalid (store notifications only). | | 7225 | INVALID_API_KEY | 401 or 403 | The API key is unknown (401), or a REST v1 endpoint was called with a public key (403). | | 7226 | BAD_REQUEST_PARAMS | 400 | A store action got parameters it cannot use, or a required field (such as `aad_attribution_token` or `generate_offers`) is missing. | | 7234 | INVALID_APPLE_SUBSCRIPTION_KEY | 400 or 500 | A StoreKit 1 receipt arrived for an App Store app without an in-app purchase key, or the key is incomplete (500, so the SDK retries once you add the key). A promotional offer cannot be signed without the key (400; the SDK reports `invalidAppleSubscriptionKeyError` for that offer). | | 7259 | NOT_FOUND | 404 | The customer, entitlement, offering or subscription does not exist. | | 7263 | INVALID_SUBSCRIBER_ATTRIBUTES | 400 | Some attributes were not saved; `attribute_errors` lists them. | | 7662 | UNSUPPORTED_RECEIPT | 400 | Receipts for this app's store are not supported yet (Amazon, Stripe, Web Billing, Paddle, Roku), including the Android SDK's Amazon receipt lookup. | | 7849 | INVALID_WEB_REDEMPTION_TOKEN | 400 | A web purchase redemption token is not valid. RevenueDot has no web purchases, so every token answers this; the SDKs return the `invalidToken` result. | | 7877 | INVALID_OPERATION_SESSION | 400 | A Web Billing checkout session does not exist. | ## REST API v2 error types ```json { "object": "error", "type": "parameter_error", "message": "app_id: Required", "param": "app_id", "doc_url": "https://revenuedot.app/docs/api/errors#parameter-error", "retryable": false } ``` Every v2 error has `object: "error"`, `type`, `message`, `doc_url` (a link to the section below) and `retryable`. `param` names the field at fault when there is one. ### parameter_error A field or query parameter is missing or invalid. `param` names it. Fix the request; do not retry it unchanged. ### resource_already_exists An object with this id or lookup key already exists (409). Fetch it instead of creating it. ### resource_missing The object does not exist in this project (404). Another project's ids also answer 404, so ids cannot be probed. ### idempotency_error Reserved for RevenueCat compatibility. RevenueDot does not send it today. ### rate_limit_error Too many requests of one kind: project invites (50 per project per day). The error is `retryable`; try again later. The dashboard's password reset and email verification endpoints answer 429 with the same `type`. ### authentication_error No API key, an unknown key, or no dashboard session (401). ### authorization_error The key lacks a permission, a public app key was used, or the action needs a dashboard admin (403). ### store_error The App Store or Google Play refused the action (422) or could not be reached (503, `retryable: true`). ### server_error RevenueDot failed (500, `retryable: true`). Retry with backoff. ### resource_locked_error Reserved for RevenueCat compatibility. RevenueDot does not send it today. ### unprocessable_entity_error The request is valid but not possible in this state or for this store (422), for example archiving the current offering or refunding an App Store purchase. ### invalid_request The body is not valid JSON (400), or a package would get two products of one app with overlapping eligibility (409). ### entity_references_archived_entities The action would make an archived object current (422). Unarchive it first. ## Related - [Authentication](https://revenuedot.app/docs/api/authentication.md) - [Troubleshooting](https://revenuedot.app/docs/help/troubleshooting.md) --- # Which endpoints do the RevenueCat SDKs call on RevenueDot? Source: https://revenuedot.app/docs/api/sdk-endpoints.md Description: Every SDK endpoint RevenueDot serves, with auth, request fields, error codes and real example responses, plus the store notification endpoints. The RevenueCat SDKs call these endpoints when their proxy URL points at RevenueDot. They take a **public app key** (`appl_`, `goog_`, `test_` ...), which is safe to ship in an app. Several also accept a **secret key** for server-side use. Errors use the SDK's error format `{ "code": 7103, "message": "..." }`; see [Errors](https://revenuedot.app/docs/api/errors.md#sdk-and-rest-v1-error-codes). Responses under `/v1` and `/rcbilling` are signed when the server has a signing key; see [Trusted Entitlements](https://revenuedot.app/docs/guides/trusted-entitlements.md). Base URL: your server, for example `http://localhost:8787` or `https://revenuedot.example.com`. The examples read `REVENUEDOT_URL`, `PUBLIC_KEY`, `SECRET_KEY` and `PROJECT_ID` from your shell. ## Operations on this page (44) - **Server**: [Server name and docs link](https://revenuedot.app/docs/api/sdk-endpoints.md#server-name-and-docs-link), [Health check](https://revenuedot.app/docs/api/sdk-endpoints.md#health-check), [Connectivity probe](https://revenuedot.app/docs/api/sdk-endpoints.md#connectivity-probe) - **Customer info**: [Get customer info](https://revenuedot.app/docs/api/sdk-endpoints.md#get-customer-info) - **Receipts**: [Post a purchase or restore](https://revenuedot.app/docs/api/sdk-endpoints.md#post-a-purchase-or-restore) - **Offerings (SDK)**: [Get offerings](https://revenuedot.app/docs/api/sdk-endpoints.md#get-offerings), [Get offerings without a user](https://revenuedot.app/docs/api/sdk-endpoints.md#get-offerings-without-a-user), [Test Store product details](https://revenuedot.app/docs/api/sdk-endpoints.md#test-store-product-details) - **Identity**: [Log in (identify)](https://revenuedot.app/docs/api/sdk-endpoints.md#log-in-identify), [Alias two app user ids](https://revenuedot.app/docs/api/sdk-endpoints.md#alias-two-app-user-ids) - **Attributes**: [Set customer attributes](https://revenuedot.app/docs/api/sdk-endpoints.md#set-customer-attributes) - **SDK support**: [Intro offer eligibility (StoreKit 1)](https://revenuedot.app/docs/api/sdk-endpoints.md#intro-offer-eligibility-storekit-1), [Sign a promotional offer (iOS)](https://revenuedot.app/docs/api/sdk-endpoints.md#sign-a-promotional-offer-ios), [Attribution data (deprecated iOS call)](https://revenuedot.app/docs/api/sdk-endpoints.md#attribution-data-deprecated-ios-call), [Apple AdServices token](https://revenuedot.app/docs/api/sdk-endpoints.md#apple-adservices-token), [SDK health report availability](https://revenuedot.app/docs/api/sdk-endpoints.md#sdk-health-report-availability), [SDK health report](https://revenuedot.app/docs/api/sdk-endpoints.md#sdk-health-report), [Product to entitlement mapping (offline entitlements)](https://revenuedot.app/docs/api/sdk-endpoints.md#product-to-entitlement-mapping-offline-entitlements), [Customer Center configuration (not built)](https://revenuedot.app/docs/api/sdk-endpoints.md#customer-center-configuration-not-built), [Customer Center support ticket (not built)](https://revenuedot.app/docs/api/sdk-endpoints.md#customer-center-support-ticket-not-built), [Virtual currency balances (not built)](https://revenuedot.app/docs/api/sdk-endpoints.md#virtual-currency-balances-not-built), [Redeem a web purchase (not available)](https://revenuedot.app/docs/api/sdk-endpoints.md#redeem-a-web-purchase-not-available), [Register an Apple external purchase token (iOS)](https://revenuedot.app/docs/api/sdk-endpoints.md#register-an-apple-external-purchase-token-ios), [Rewarded ad verification (not available)](https://revenuedot.app/docs/api/sdk-endpoints.md#rewarded-ad-verification-not-available), [Amazon receipt details (not supported)](https://revenuedot.app/docs/api/sdk-endpoints.md#amazon-receipt-details-not-supported), [Paywall workflows (web SDK)](https://revenuedot.app/docs/api/sdk-endpoints.md#paywall-workflows-web-sdk), [One paywall workflow (web SDK)](https://revenuedot.app/docs/api/sdk-endpoints.md#one-paywall-workflow-web-sdk), [Restore eligibility (StoreKit 2)](https://revenuedot.app/docs/api/sdk-endpoints.md#restore-eligibility-storekit-2), [Remote config (none yet)](https://revenuedot.app/docs/api/sdk-endpoints.md#remote-config-none-yet), [Remote config (none yet)](https://revenuedot.app/docs/api/sdk-endpoints.md#remote-config-none-yet), [SDK paywall and feature events (accepted, not stored)](https://revenuedot.app/docs/api/sdk-endpoints.md#sdk-paywall-and-feature-events-accepted-not-stored), [SDK diagnostics (accepted, not stored)](https://revenuedot.app/docs/api/sdk-endpoints.md#sdk-diagnostics-accepted-not-stored) - **Web Billing**: [Web offering products](https://revenuedot.app/docs/api/sdk-endpoints.md#web-offering-products), [Start a hosted web checkout (not available)](https://revenuedot.app/docs/api/sdk-endpoints.md#start-a-hosted-web-checkout-not-available), [Web Billing purchase (not available)](https://revenuedot.app/docs/api/sdk-endpoints.md#web-billing-purchase-not-available), [Prepare a Web Billing checkout (not available)](https://revenuedot.app/docs/api/sdk-endpoints.md#prepare-a-web-billing-checkout-not-available), [Start a Web Billing checkout (not available)](https://revenuedot.app/docs/api/sdk-endpoints.md#start-a-web-billing-checkout-not-available), [Web Billing checkout status](https://revenuedot.app/docs/api/sdk-endpoints.md#web-billing-checkout-status), [Refresh Web Billing checkout pricing](https://revenuedot.app/docs/api/sdk-endpoints.md#refresh-web-billing-checkout-pricing), [Complete a Web Billing checkout](https://revenuedot.app/docs/api/sdk-endpoints.md#complete-a-web-billing-checkout), [Web checkout branding](https://revenuedot.app/docs/api/sdk-endpoints.md#web-checkout-branding) - **Store notifications**: [App Store Server Notifications v2](https://revenuedot.app/docs/api/sdk-endpoints.md#app-store-server-notifications-v2), [Google Play real-time developer notifications (Pub/Sub push)](https://revenuedot.app/docs/api/sdk-endpoints.md#google-play-real-time-developer-notifications-pubsub-push) - **Response signing**: [Public key for response signatures](https://revenuedot.app/docs/api/sdk-endpoints.md#public-key-for-response-signatures) ## Server Health and server info. No API key. ### Server name and docs link `GET /` · Auth: none Answers a small JSON document. The Docker health check calls it. **Example request** ```bash curl -s "$REVENUEDOT_URL/" ``` **Responses** - **200**: Server info. Example 200 response: ```json { "name": "RevenueDot", "docs": "https://revenuedot.app/docs" } ``` ### Health check `GET /v1/health` · Auth: none Needs no API key. Use it for load balancer and uptime checks. **Example request** ```bash curl -s "$REVENUEDOT_URL/v1/health" ``` **Responses** - **200**: The server is up. Example 200 response: ```json { "status": "ok" } ``` ### Connectivity probe `GET /v1/health/connectivity` · Auth: none Needs no API key. The iOS SDK probes it only with its internal API failover setting, which is off by default. **Example request** ```bash curl -s "$REVENUEDOT_URL/v1/health/connectivity" ``` **Responses** - **200**: The server is up. Example 200 response: ```json { "status": "ok" } ``` ## Customer info The customer's entitlements, subscriptions and one-time purchases, as the SDK decodes them into `CustomerInfo`. ### Get customer info `GET /v1/subscribers/{app_user_id}` · Auth: public app key or secret key What `Purchases.getCustomerInfo()` calls. Creates the customer when the app user id is new (answer 201). With a secret key the answer also has `subscriber_attributes`. Entitlements are listed even after they expire; an entitlement is active while `expires_date` is null or in the future. **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `app_user_id` | string | yes | App user id, URL-encoded (anonymous ids look like `$RCAnonymousID:...`). | **Headers** | Name | Type | Required | Description | |---|---|---|---| | `X-Nonce` | string | no | Base64 nonce the SDK sends when entitlement verification is on; it is part of the signed message. | **Example request** ```bash curl -s "$REVENUEDOT_URL/v1/subscribers/user_1" -H "Authorization: Bearer $PUBLIC_KEY" ``` **Responses** - **200**: Customer info. Returns [CustomerInfo](https://revenuedot.app/docs/api/sdk-endpoints.md#customerinfo). - **201**: Customer info of a customer created by this call. Returns [CustomerInfo](https://revenuedot.app/docs/api/sdk-endpoints.md#customerinfo). - **400**: Bad request. For receipts, a 4xx tells the SDK the purchase can never be accepted, so it finishes the transaction. Returns [V1Error](https://revenuedot.app/docs/api/sdk-endpoints.md#v1error). - **401**: Unknown API key. Returns [V1Error](https://revenuedot.app/docs/api/sdk-endpoints.md#v1error). Example 200 response: ```json { "request_date": "2026-09-30T20:41:54Z", "request_date_ms": 1790800914034, "subscriber": { "entitlements": { "pro": { "expires_date": "2026-10-30T20:41:54Z", "grace_period_expires_date": null, "product_identifier": "pro_monthly", "purchase_date": "2026-09-30T20:41:54Z" } }, "first_seen": "2026-09-30T20:41:54Z", "last_seen": "2026-09-30T20:41:54Z", "management_url": null, "non_subscriptions": {}, "original_app_user_id": "user_1", "original_application_version": null, "original_purchase_date": "2026-09-30T20:41:54Z", "other_purchases": {}, "subscriptions": { "pro_monthly": { "auto_resume_date": null, "billing_issues_detected_at": null, "display_name": null, "expires_date": "2026-10-30T20:41:54Z", "grace_period_expires_date": null, "is_sandbox": true, "management_url": null, "original_purchase_date": "2026-09-30T20:41:54Z", "ownership_type": "PURCHASED", "period_type": "normal", "purchase_date": "2026-09-30T20:41:54Z", "refunded_at": null, "store": "test_store", "store_transaction_id": "test_1790800914000_quickstart", "unsubscribe_detected_at": null, "price": { "amount": 9.99, "currency": "USD" } } } } } ``` ## Receipts Purchases, restores and syncs. RevenueDot verifies them with the store. ### Post a purchase or restore `POST /v1/receipts` · Auth: public app key or secret key Every purchase, restore and `syncPurchases()` ends here. RevenueDot verifies the purchase with the store, saves it, records events and answers the updated customer info. - **App Store:** `fetch_token` is a StoreKit 2 signed transaction (JWS), a StoreKit 1 app receipt (base64) or an Xcode StoreKit test receipt. With the app's in-app purchase key, Apple's App Store Server API supplies the full history and renewal state. - **Google Play:** `fetch_token` is the purchase token. RevenueDot checks it with the Play Developer API and acknowledges it. - **Test Store:** `fetch_token` is `test__`. Any such token is accepted. **4xx or 5xx matters.** A 4xx tells the SDK the purchase can never be accepted, so it finishes the transaction. RevenueDot answers 5xx for its own and the store's temporary failures so the SDK keeps the purchase and retries. With a secret key, send `X-Platform` so RevenueDot knows which app the receipt belongs to. **Headers** | Name | Type | Required | Description | |---|---|---|---| | `X-Platform` | string | no | SDK platform (ios, android, macos, web ...). With a secret key it picks the project's app for that platform. | | `X-Nonce` | string | no | Base64 nonce the SDK sends when entitlement verification is on; it is part of the signed message. | | `X-Is-Sandbox` | string | no | `true` when the SDK knows the purchase is sandbox (used for StoreKit 1 receipts without an environment). | **Request body** (`application/json`) | Field | Type | Required | Description | |---|---|---|---| | `app_user_id` | string | yes | The customer posting the receipt. | | `fetch_token` | string | no | Receipt, signed transaction, purchase token or Test Store token. | | `app_transaction` | string | no | StoreKit 2 app transaction JWS (accepted; not required). | | `transaction_id` | string | no | Store transaction id. | | `product_id` | string | no | Product being bought. | | `product_ids` | array of string | no | | | `platform_product_ids` | array of object | no | | | `platform_product_ids[].product_id` | string | no | | | `platform_product_ids[].base_plan_id` | string | no | | | `platform_product_ids[].offer_id` | string | no | | | `price` | number | no | | | `currency` | string | no | | | `store_country` | string | no | | | `normal_duration` | string | no | ISO 8601 period of the product. | | `is_restore` | boolean | no | | | `store_user_id` | string | no | | | `presented_offering_identifier` | string | no | Offering the purchase was made from; it appears in webhooks. | | `attributes` | object | no | Customer attributes to save with the purchase. | **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/v1/receipts" -H "Authorization: Bearer $PUBLIC_KEY" \ -H "Content-Type: application/json" -d '{"app_user_id":"user_1","fetch_token":"test_1790800914000_quickstart","product_id":"pro_monthly","price":9.99,"currency":"USD","presented_offering_identifier":"default"}' ``` **Responses** - **200**: Updated customer info, plus `purchased_products`. Returns [ReceiptResponse](https://revenuedot.app/docs/api/sdk-endpoints.md#receiptresponse). - **400**: Bad request. For receipts, a 4xx tells the SDK the purchase can never be accepted, so it finishes the transaction. Returns [V1Error](https://revenuedot.app/docs/api/sdk-endpoints.md#v1error). - **401**: Unknown API key. Returns [V1Error](https://revenuedot.app/docs/api/sdk-endpoints.md#v1error). - **500**: Server error. The SDK keeps the purchase and retries. Returns [V1Error](https://revenuedot.app/docs/api/sdk-endpoints.md#v1error). - **503**: The store could not be reached. Retry later. Returns [V1Error](https://revenuedot.app/docs/api/sdk-endpoints.md#v1error). Example 200 response: ```json { "request_date": "2026-09-30T20:41:54Z", "request_date_ms": 1790800914034, "subscriber": { "entitlements": { "pro": { "expires_date": "2026-10-30T20:41:54Z", "grace_period_expires_date": null, "product_identifier": "pro_monthly", "purchase_date": "2026-09-30T20:41:54Z" } }, "first_seen": "2026-09-30T20:41:54Z", "last_seen": "2026-09-30T20:41:54Z", "management_url": null, "non_subscriptions": {}, "original_app_user_id": "user_1", "original_application_version": null, "original_purchase_date": "2026-09-30T20:41:54Z", "other_purchases": {}, "subscriptions": { "pro_monthly": { "auto_resume_date": null, "billing_issues_detected_at": null, "display_name": null, "expires_date": "2026-10-30T20:41:54Z", "grace_period_expires_date": null, "is_sandbox": true, "management_url": null, "original_purchase_date": "2026-09-30T20:41:54Z", "ownership_type": "PURCHASED", "period_type": "normal", "purchase_date": "2026-09-30T20:41:54Z", "refunded_at": null, "store": "test_store", "store_transaction_id": "test_1790800914000_quickstart", "unsubscribe_detected_at": null, "price": { "amount": 9.99, "currency": "USD" } } } }, "purchased_products": { "pro_monthly": { "should_consume": false } } } ``` ## Offerings (SDK) What the paywall shows. ### Get offerings `GET /v1/subscribers/{app_user_id}/offerings` · Auth: public app key or secret key What `Purchases.getOfferings()` calls. Lists active offerings with the packages whose product belongs to the calling app. `current_offering_id` is the customer's override when one is set. **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `app_user_id` | string | yes | App user id, URL-encoded (anonymous ids look like `$RCAnonymousID:...`). | **Example request** ```bash curl -s "$REVENUEDOT_URL/v1/subscribers/user_1/offerings" -H "Authorization: Bearer $PUBLIC_KEY" ``` **Responses** - **200**: Offerings. Returns [Offerings](https://revenuedot.app/docs/api/sdk-endpoints.md#offerings). - **401**: Unknown API key. Returns [V1Error](https://revenuedot.app/docs/api/sdk-endpoints.md#v1error). Example 200 response: ```json { "current_offering_id": "default", "offerings": [ { "description": "Standard plans", "identifier": "default", "metadata": null, "packages": [ { "identifier": "$rc_monthly", "platform_product_identifier": "pro_monthly" }, { "identifier": "$rc_annual", "platform_product_identifier": "pro_annual" }, { "identifier": "$rc_lifetime", "platform_product_identifier": "pro_lifetime" } ] } ] } ``` ### Get offerings without a user `GET /v1/offerings` · Auth: public app key or secret key Same answer as the per-user call, without a customer override. **Example request** ```bash curl -s "$REVENUEDOT_URL/v1/offerings" -H "Authorization: Bearer $PUBLIC_KEY" ``` **Responses** - **200**: Offerings. Returns [Offerings](https://revenuedot.app/docs/api/sdk-endpoints.md#offerings). - **401**: Unknown API key. Returns [V1Error](https://revenuedot.app/docs/api/sdk-endpoints.md#v1error). ### Test Store product details `GET /rcbilling/v1/subscribers/{app_user_id}/products` · Auth: public app key Product details the SDK needs for Test Store (and web) products, in the web billing products shape. Prices are 0 until the catalog stores Test Store prices. **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `app_user_id` | string | yes | App user id, URL-encoded (anonymous ids look like `$RCAnonymousID:...`). | **Query parameters** | Name | Type | Required | Description | |---|---|---|---| | `id` | array of string | no | Product ids; repeat the parameter. None lists every product of the app. | **Example request** ```bash curl -s "$REVENUEDOT_URL/rcbilling/v1/subscribers/user_1/products" -H "Authorization: Bearer $PUBLIC_KEY" ``` **Responses** - **200**: Product details. - **401**: Unknown API key. Returns [V1Error](https://revenuedot.app/docs/api/sdk-endpoints.md#v1error). Example 200 response: ```json { "product_details": [ { "identifier": "pro_monthly", "product_type": "subscription", "title": "Pro monthly", "description": null, "current_price": { "amount": 0, "amount_micros": 0, "currency": "USD" }, "normal_period_duration": "P1M", "default_purchase_option_id": "base", "default_subscription_option_id": "base", "purchase_options": { "base": { "id": "base", "price_id": "base", "base": { "period_duration": "P1M", "cycle_count": 1, "price": { "amount": 0, "amount_micros": 0, "currency": "USD" } }, "base_price": null, "trial": null, "intro_price": null } }, "subscription_options": { "base": { "id": "base", "price_id": "base", "base": { "period_duration": "P1M", "cycle_count": 1, "price": { "amount": 0, "amount_micros": 0, "currency": "USD" } }, "base_price": null, "trial": null, "intro_price": null } } } ] } ``` ## Identity `logIn` and aliases. ### Log in (identify) `POST /v1/subscribers/identify` · Auth: public app key or secret key What `Purchases.logIn()` calls. When `new_app_user_id` is new and the current id is anonymous with no other ids, the anonymous customer takes the new id (201). When `new_app_user_id` exists, an anonymous-only current customer is merged into it (200). See [Customers and app user IDs](https://revenuedot.app/docs/concepts/customers-and-app-user-ids.md). **Request body** (`application/json`) | Field | Type | Required | Description | |---|---|---|---| | `app_user_id` | string | yes | The current app user id. | | `new_app_user_id` | string | yes | Your user id. | **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/v1/subscribers/identify" -H "Authorization: Bearer $PUBLIC_KEY" \ -H "Content-Type: application/json" -d '{"app_user_id":"$RCAnonymousID:abc123","new_app_user_id":"user_2"}' ``` **Responses** - **200**: The user existed. Returns [CustomerInfo](https://revenuedot.app/docs/api/sdk-endpoints.md#customerinfo). - **201**: The user is new. Returns [CustomerInfo](https://revenuedot.app/docs/api/sdk-endpoints.md#customerinfo). - **400**: Bad request. For receipts, a 4xx tells the SDK the purchase can never be accepted, so it finishes the transaction. Returns [V1Error](https://revenuedot.app/docs/api/sdk-endpoints.md#v1error). - **401**: Unknown API key. Returns [V1Error](https://revenuedot.app/docs/api/sdk-endpoints.md#v1error). Example 200 response: ```json { "request_date": "2026-09-30T20:41:54Z", "request_date_ms": 1790800914034, "subscriber": { "entitlements": { "pro": { "expires_date": "2026-10-30T20:41:54Z", "grace_period_expires_date": null, "product_identifier": "pro_monthly", "purchase_date": "2026-09-30T20:41:54Z" } }, "first_seen": "2026-09-30T20:41:54Z", "last_seen": "2026-09-30T20:41:54Z", "management_url": null, "non_subscriptions": {}, "original_app_user_id": "user_1", "original_application_version": null, "original_purchase_date": "2026-09-30T20:41:54Z", "other_purchases": {}, "subscriptions": { "pro_monthly": { "auto_resume_date": null, "billing_issues_detected_at": null, "display_name": null, "expires_date": "2026-10-30T20:41:54Z", "grace_period_expires_date": null, "is_sandbox": true, "management_url": null, "original_purchase_date": "2026-09-30T20:41:54Z", "ownership_type": "PURCHASED", "period_type": "normal", "purchase_date": "2026-09-30T20:41:54Z", "refunded_at": null, "store": "test_store", "store_transaction_id": "test_1790800914000_quickstart", "unsubscribe_detected_at": null, "price": { "amount": 9.99, "currency": "USD" } } } } } ``` ### Alias two app user ids `POST /v1/subscribers/{app_user_id}/alias` · Auth: public app key or secret key Links `new_app_user_id` to the customer with the same merge rules as log in. The Android SDK uses it for Block Store recovery. **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `app_user_id` | string | yes | App user id, URL-encoded (anonymous ids look like `$RCAnonymousID:...`). | **Request body** (`application/json`) | Field | Type | Required | Description | |---|---|---|---| | `new_app_user_id` | string | yes | | **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/v1/subscribers/user_1/alias" -H "Authorization: Bearer $PUBLIC_KEY" ``` **Responses** - **200**: Accepted. - **400**: Bad request. For receipts, a 4xx tells the SDK the purchase can never be accepted, so it finishes the transaction. Returns [V1Error](https://revenuedot.app/docs/api/sdk-endpoints.md#v1error). - **401**: Unknown API key. Returns [V1Error](https://revenuedot.app/docs/api/sdk-endpoints.md#v1error). Example 200 response: ```json {} ``` ## Attributes Customer attributes such as `$email`. ### Set customer attributes `POST /v1/subscribers/{app_user_id}/attributes` · Auth: public app key or secret key Saves attributes such as `$email`, `$displayName` or your own keys. A null value deletes the attribute. An invalid `$email` is refused with 7263; the other attributes are saved. `collectDeviceIdentifiers()` sends `$ip` and `$deviceVersion` as `"true"`: RevenueDot stores the request's IP address and the device and OS from the SDK's headers instead. **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `app_user_id` | string | yes | App user id, URL-encoded (anonymous ids look like `$RCAnonymousID:...`). | **Request body** (`application/json`) | Field | Type | Required | Description | |---|---|---|---| | `attributes` | object | yes | | **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/v1/subscribers/user_1/attributes" -H "Authorization: Bearer $PUBLIC_KEY" \ -H "Content-Type: application/json" -d '{"attributes":{"$email":{"value":"ana@example.com","updated_at_ms":1790800914000}}}' ``` **Responses** - **200**: Saved. - **400**: Some attributes were not saved. Returns [V1Error](https://revenuedot.app/docs/api/sdk-endpoints.md#v1error). - **401**: Unknown API key. Returns [V1Error](https://revenuedot.app/docs/api/sdk-endpoints.md#v1error). Example 200 response: ```json {} ``` ## SDK support Endpoints the SDK calls for features RevenueDot answers minimally, so the SDK keeps working. ### Intro offer eligibility (StoreKit 1) `POST /v1/subscribers/{app_user_id}/intro_eligibility` · Auth: public app key or secret key Answers `null` (unknown) for every product, so the SDK decides eligibility on the device. **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `app_user_id` | string | yes | App user id, URL-encoded (anonymous ids look like `$RCAnonymousID:...`). | **Request body** (`application/json`) | Field | Type | Required | Description | |---|---|---|---| | `product_identifiers` | array of string | no | | **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/v1/subscribers/user_1/intro_eligibility" -H "Authorization: Bearer $PUBLIC_KEY" ``` **Responses** - **200**: Eligibility per product. Example 200 response: ```json { "pro_monthly": null } ``` ### Sign a promotional offer (iOS) `POST /v1/offers` · Auth: public app key or secret key What `Purchases.promotionalOffer(forProductDiscount:product:)` calls. RevenueDot signs each offer with the App Store app's In-App Purchase key (`key_id`, `issuer_id` and `private_key` in the app's credentials) the way Apple verifies it: ECDSA P-256 with SHA-256 over the bundle id, key id, product id, offer id, app account token, nonce and timestamp, DER-encoded and base64 ([Apple's format](https://developer.apple.com/documentation/storekit/generating-a-signature-for-promotional-offers)). The app account token matches what the SDK puts on the payment: with StoreKit 2 the lowercase app user id when it is a UUID and empty otherwise; with StoreKit 1 the app user id. Without an In-App Purchase key the answer is 400 with code 7234, which the SDK reports as `invalidAppleSubscriptionKeyError` for that offer only. **Request body** (`application/json`) | Field | Type | Required | Description | |---|---|---|---| | `app_user_id` | string | yes | | | `fetch_token` | string | no | The receipt or signed transaction; not needed for signing. | | `generate_offers` | array of object | yes | | | `generate_offers[].offer_id` | string | yes | Promotional offer id from App Store Connect. | | `generate_offers[].product_id` | string | yes | | **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/v1/offers" -H "Authorization: Bearer $PUBLIC_KEY" \ -H "Content-Type: application/json" -d '{"app_user_id":"user_1","fetch_token":"…","generate_offers":[{"offer_id":"winback_50","product_id":"pro_monthly"}]}' ``` **Responses** - **200**: One signature per offer. - **400**: No In-App Purchase key, or no offers. Returns [V1Error](https://revenuedot.app/docs/api/sdk-endpoints.md#v1error). - **401**: Unknown API key. Returns [V1Error](https://revenuedot.app/docs/api/sdk-endpoints.md#v1error). Example 200 response: ```json { "offers": [ { "key_id": "2X9R4HXF34", "offer_id": "winback_50", "product_id": "pro_monthly", "signature_data": { "nonce": "0f3c2a8e-5d7b-4d7e-9a53-3b8f2c1e6d40", "signature": "MEUCIQDD…", "timestamp": 1790800914034 } } ] } ``` ### Attribution data (deprecated iOS call) `POST /v1/subscribers/{app_user_id}/attribution` · Auth: public app key or secret key What the deprecated `Purchases.addAttributionData` calls. The advertising identifiers in `data` (`rc_idfa`, `rc_idfv`, `rc_gps_adid`, `rc_ip_address`) become `$idfa`, `$idfv`, `$gpsAdId` and `$ip`. For Apple Search Ads (`network` 0) with `iad-attribution` true, the iAd fields become `$mediaSource` ("Apple Search Ads"), `$campaign`, `$adGroup`, `$keyword`, `$creative` and the `$appleAds*` ids. Attribution is write-once: a campaign attribute the customer already has is kept. **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `app_user_id` | string | yes | App user id, URL-encoded (anonymous ids look like `$RCAnonymousID:...`). | **Request body** (`application/json`) | Field | Type | Required | Description | |---|---|---|---| | `network` | integer | yes | The SDK's AttributionNetwork: 0 Apple Search Ads. | | `data` | object | yes | | **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/v1/subscribers/user_1/attribution" -H "Authorization: Bearer $PUBLIC_KEY" \ -H "Content-Type: application/json" -d '{"network":0,"data":{"rc_idfv":"4CEE1BEE-3C19-4591-9E34-1AD968D7B609","Version3.1":{"iad-attribution":"true","iad-campaign-name":"Spring","iad-keyword":"scanner"}}}' ``` **Responses** - **200**: Stored. - **400**: Bad request. For receipts, a 4xx tells the SDK the purchase can never be accepted, so it finishes the transaction. Returns [V1Error](https://revenuedot.app/docs/api/sdk-endpoints.md#v1error). - **401**: Unknown API key. Returns [V1Error](https://revenuedot.app/docs/api/sdk-endpoints.md#v1error). Example 200 response: ```json {} ``` ### Apple AdServices token `POST /v1/subscribers/{app_user_id}/adservices_attribution` · Auth: public app key or secret key What `enableAdServicesAttributionTokenCollection()` sends once per install (the same token can also arrive as `aad_attribution_token` on a receipt). After answering, RevenueDot looks the token up with [Apple's attribution API](https://developer.apple.com/documentation/adservices/aaattribution/attributiontoken()), retrying a 404 or 5xx 3 times 5 seconds apart, and stores an attributed install as `$mediaSource` ("Apple Search Ads"), `$campaign`, `$adGroup`, `$keyword`, `$ad`, `$appleAdsCampaignId`, `$appleAdsAdGroupId`, `$appleAdsKeywordId`, `$appleAdsAdId`, `$appleAdsOrgId`, `$appleAdsCountryOrRegion`, `$claimType` and `$conversionType`. Apple returns ids, not names. They show on the customer page and in every webhook's `subscriber_attributes`. **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `app_user_id` | string | yes | App user id, URL-encoded (anonymous ids look like `$RCAnonymousID:...`). | **Request body** (`application/json`) | Field | Type | Required | Description | |---|---|---|---| | `aad_attribution_token` | string | yes | The token from AAAttribution.attributionToken(). | **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/v1/subscribers/user_1/adservices_attribution" -H "Authorization: Bearer $PUBLIC_KEY" \ -H "Content-Type: application/json" -d '{"aad_attribution_token":"wD3Ma…"}' ``` **Responses** - **200**: Accepted; the lookup runs after the answer. - **400**: No token. Returns [V1Error](https://revenuedot.app/docs/api/sdk-endpoints.md#v1error). - **401**: Unknown API key. Returns [V1Error](https://revenuedot.app/docs/api/sdk-endpoints.md#v1error). Example 200 response: ```json {} ``` ### SDK health report availability `GET /v1/subscribers/{app_user_id}/health_report_availability` · Auth: none **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `app_user_id` | string | yes | App user id, URL-encoded (anonymous ids look like `$RCAnonymousID:...`). | **Example request** ```bash curl -s "$REVENUEDOT_URL/v1/subscribers/user_1/health_report_availability" ``` **Responses** - **200**: No report logs. Example 200 response: ```json { "report_logs": false } ``` ### SDK health report `GET /v1/subscribers/{app_user_id}/health_report` · Auth: public app key **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `app_user_id` | string | yes | App user id, URL-encoded (anonymous ids look like `$RCAnonymousID:...`). | **Example request** ```bash curl -s "$REVENUEDOT_URL/v1/subscribers/user_1/health_report" -H "Authorization: Bearer $PUBLIC_KEY" ``` **Responses** - **200**: Always passed. Example 200 response: ```json { "status": "passed", "project_id": "proj18pzzkao", "app_id": "appvnrm0a5h", "checks": [] } ``` ### Product to entitlement mapping (offline entitlements) `GET /v1/product_entitlement_mapping` · Auth: public app key or secret key Lets the SDK grant entitlements while the server cannot be reached. **Example request** ```bash curl -s "$REVENUEDOT_URL/v1/product_entitlement_mapping" -H "Authorization: Bearer $PUBLIC_KEY" ``` **Responses** - **200**: The mapping. Example 200 response: ```json { "product_entitlement_mapping": { "pro_monthly": { "product_identifier": "pro_monthly", "entitlements": [ "pro" ] } } } ``` ### Customer Center configuration (not built) `GET /v1/customercenter/{app_user_id}` · Auth: public app key Always 404 with code 7259: the SDK returns an error and the Customer Center screen shows its error state. Customer Center configuration is planned for Tier 2. **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `app_user_id` | string | yes | App user id, URL-encoded (anonymous ids look like `$RCAnonymousID:...`). | **Example request** ```bash curl -s "$REVENUEDOT_URL/v1/customercenter/user_1" -H "Authorization: Bearer $PUBLIC_KEY" ``` **Responses** - **404**: Not configured. Returns [V1Error](https://revenuedot.app/docs/api/sdk-endpoints.md#v1error). ### Customer Center support ticket (not built) `POST /v1/customercenter/support/create-ticket` · Auth: public app key **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/v1/customercenter/support/create-ticket" -H "Authorization: Bearer $PUBLIC_KEY" ``` **Responses** - **200**: Not sent. Example 200 response: ```json { "sent": false } ``` ### Virtual currency balances (not built) `GET /v1/subscribers/{app_user_id}/virtual_currencies` · Auth: public app key **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `app_user_id` | string | yes | App user id, URL-encoded (anonymous ids look like `$RCAnonymousID:...`). | **Example request** ```bash curl -s "$REVENUEDOT_URL/v1/subscribers/user_1/virtual_currencies" -H "Authorization: Bearer $PUBLIC_KEY" ``` **Responses** - **200**: Empty balances. Example 200 response: ```json { "virtual_currencies": {} } ``` ### Redeem a web purchase (not available) `POST /v1/subscribers/redeem_purchase` · Auth: public app key What `Purchases.redeemWebPurchase()` calls with the `redemption_token` from a redemption deep link. RevenueDot takes no web payments, so no token is valid: 400 with code 7849, which the SDKs return as the `invalidToken` result. **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/v1/subscribers/redeem_purchase" -H "Authorization: Bearer $PUBLIC_KEY" ``` **Responses** - **400**: Invalid token. Returns [V1Error](https://revenuedot.app/docs/api/sdk-endpoints.md#v1error). - **401**: Unknown API key. Returns [V1Error](https://revenuedot.app/docs/api/sdk-endpoints.md#v1error). ### Register an Apple external purchase token (iOS) `POST /v1/external_purchase_tokens` · Auth: public app key Part of Apple's external purchase and link-out flows, before a web checkout. The token is acknowledged with an id, which is all the SDK reads; the web checkout that follows is not available (see `/rcbilling/v1/hosted-checkout`). **Request body** (`application/json`) | Field | Type | Required | Description | |---|---|---|---| | `app_user_id` | string | yes | | | `purchase_type` | `IN_APP`, `LINK_OUT` | yes | | | `token` | string | no | Apple's external purchase token, when there is one. | **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/v1/external_purchase_tokens" -H "Authorization: Bearer $PUBLIC_KEY" ``` **Responses** - **200**: Registered. - **401**: Unknown API key. Returns [V1Error](https://revenuedot.app/docs/api/sdk-endpoints.md#v1error). Example 200 response: ```json { "id": "ept3b1f0c9e2d8a4f6b9c7e5d3a1b2c4d6e", "purchase_type": "LINK_OUT", "is_sandbox": true, "token_source": "APPLE_SDK" } ``` ### Rewarded ad verification (not available) `GET /v1/subscribers/{app_user_id}/ads/reward_verifications/{client_transaction_id}` · Auth: public app key What `pollRewardVerification` polls. There is no server-side ad verification, so the answer is always the final `failed`, and the SDK stops after one request. **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `app_user_id` | string | yes | App user id, URL-encoded (anonymous ids look like `$RCAnonymousID:...`). | | `client_transaction_id` | string | yes | From `generateRewardVerificationToken`. | **Example request** ```bash curl -s "$REVENUEDOT_URL/v1/subscribers/user_1/ads/reward_verifications/$CLIENT_TRANSACTION_ID" -H "Authorization: Bearer $PUBLIC_KEY" ``` **Responses** - **200**: Failed. - **401**: Unknown API key. Returns [V1Error](https://revenuedot.app/docs/api/sdk-endpoints.md#v1error). Example 200 response: ```json { "status": "failed", "reward": null, "failure_reason": "not_supported", "message": "Server-side reward verification is not available on RevenueDot." } ``` ### Amazon receipt details (not supported) `GET /v1/receipts/amazon/{store_user_id}/{receipt_id}` · Auth: public app key The Android SDK asks for it on Amazon subscription purchases. Amazon Appstore purchases are not supported: 400 with code 7662, the same answer as a receipt post for an Amazon app, which leaves the purchase unconsumed. **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `store_user_id` | string | yes | | | `receipt_id` | string | yes | Not encoded by the SDK; may contain `/`. | **Example request** ```bash curl -s "$REVENUEDOT_URL/v1/receipts/amazon/$STORE_USER_ID/$RECEIPT_ID" -H "Authorization: Bearer $PUBLIC_KEY" ``` **Responses** - **400**: Not supported. Returns [V1Error](https://revenuedot.app/docs/api/sdk-endpoints.md#v1error). - **401**: Unknown API key. Returns [V1Error](https://revenuedot.app/docs/api/sdk-endpoints.md#v1error). ### Paywall workflows (web SDK) `GET /v1/subscribers/{app_user_id}/workflows` · Auth: public app key What purchases-js `presentPaywall` asks first. There are no workflows, so the SDK uses the offering's own paywall. **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `app_user_id` | string | yes | App user id, URL-encoded (anonymous ids look like `$RCAnonymousID:...`). | **Query parameters** | Name | Type | Required | Description | |---|---|---|---| | `type` | string | no | `paywall`. | **Example request** ```bash curl -s "$REVENUEDOT_URL/v1/subscribers/user_1/workflows" -H "Authorization: Bearer $PUBLIC_KEY" ``` **Responses** - **200**: No workflows. - **401**: Unknown API key. Returns [V1Error](https://revenuedot.app/docs/api/sdk-endpoints.md#v1error). Example 200 response: ```json { "workflows": [], "ui_config": {} } ``` ### One paywall workflow (web SDK) `GET /v1/subscribers/{app_user_id}/workflows/{workflow_id}` · Auth: public app key Never called, because the workflow list is empty. **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `app_user_id` | string | yes | App user id, URL-encoded (anonymous ids look like `$RCAnonymousID:...`). | | `workflow_id` | string | yes | | **Example request** ```bash curl -s "$REVENUEDOT_URL/v1/subscribers/user_1/workflows/$WORKFLOW_ID" -H "Authorization: Bearer $PUBLIC_KEY" ``` **Responses** - **401**: Unknown API key. Returns [V1Error](https://revenuedot.app/docs/api/sdk-endpoints.md#v1error). - **404**: No such workflow. Returns [V1Error](https://revenuedot.app/docs/api/sdk-endpoints.md#v1error). ### Restore eligibility (StoreKit 2) `POST /v1/subscribers/{app_user_id}/restore/eligibility` · Auth: public app key **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `app_user_id` | string | yes | App user id, URL-encoded (anonymous ids look like `$RCAnonymousID:...`). | **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/v1/subscribers/user_1/restore/eligibility" -H "Authorization: Bearer $PUBLIC_KEY" ``` **Responses** - **200**: Always allowed. Example 200 response: ```json { "is_purchase_allowed_by_restore_behavior": true } ``` ### Remote config (none yet) `GET /v1/config/{domain}` · Auth: public app key Answers 204 (no config). `getOfferings` waits on this call. **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `domain` | string | yes | Config domain the SDK asks for (for example `app`). | **Example request** ```bash curl -s "$REVENUEDOT_URL/v1/config/app" -H "Authorization: Bearer $PUBLIC_KEY" ``` **Responses** - **204**: No config. ### Remote config (none yet) `POST /v1/config/{domain}` · Auth: public app key **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `domain` | string | yes | Config domain the SDK asks for (for example `app`). | **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/v1/config/app" -H "Authorization: Bearer $PUBLIC_KEY" ``` **Responses** - **204**: No config. ### SDK paywall and feature events (accepted, not stored) `POST /v1/events` · Auth: public app key Accepted so the SDK does not resend them forever. **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/v1/events" -H "Authorization: Bearer $PUBLIC_KEY" ``` **Responses** - **200**: Accepted. Example 200 response: ```json {} ``` ### SDK diagnostics (accepted, not stored) `POST /v1/diagnostics` · Auth: public app key **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/v1/diagnostics" -H "Authorization: Bearer $PUBLIC_KEY" ``` **Responses** - **200**: Accepted. Example 200 response: ```json {} ``` ## Web Billing Web checkout calls from the iOS SDK and purchases-js. RevenueDot takes no web payments, so a checkout answers an error the SDK shows as a failed purchase. ### Web offering products `GET /rcbilling/v1/subscribers/{app_user_id}/offering_products` · Auth: public app key Defined in the iOS SDK with no caller. There are no web offerings. **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `app_user_id` | string | yes | App user id, URL-encoded (anonymous ids look like `$RCAnonymousID:...`). | **Example request** ```bash curl -s "$REVENUEDOT_URL/rcbilling/v1/subscribers/user_1/offering_products" -H "Authorization: Bearer $PUBLIC_KEY" ``` **Responses** - **200**: No web offerings. - **401**: Unknown API key. Returns [V1Error](https://revenuedot.app/docs/api/sdk-endpoints.md#v1error). Example 200 response: ```json { "offerings": {} } ``` ### Start a hosted web checkout (not available) `POST /rcbilling/v1/hosted-checkout` · Auth: public app key The iOS SDK's paywall web checkout. RevenueDot takes no payments: 400 with code 7000, and the SDK returns `failed` for the checkout without retrying. **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/rcbilling/v1/hosted-checkout" -H "Authorization: Bearer $PUBLIC_KEY" ``` **Responses** - **400**: Not available. Returns [V1Error](https://revenuedot.app/docs/api/sdk-endpoints.md#v1error). - **401**: Unknown API key. Returns [V1Error](https://revenuedot.app/docs/api/sdk-endpoints.md#v1error). ### Web Billing purchase (not available) `POST /rcbilling/v1/purchase` · Auth: public app key Defined in purchases-js with no caller. 400 with code 7000. **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/rcbilling/v1/purchase" -H "Authorization: Bearer $PUBLIC_KEY" ``` **Responses** - **400**: Not available. Returns [V1Error](https://revenuedot.app/docs/api/sdk-endpoints.md#v1error). - **401**: Unknown API key. Returns [V1Error](https://revenuedot.app/docs/api/sdk-endpoints.md#v1error). ### Prepare a Web Billing checkout (not available) `POST /rcbilling/v1/checkout/prepare` · Auth: public app key purchases-js with an `rcb_` key. 400 with code 7000: the purchase fails with an error in the SDK's purchase screen. **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/rcbilling/v1/checkout/prepare" -H "Authorization: Bearer $PUBLIC_KEY" ``` **Responses** - **400**: Not available. Returns [V1Error](https://revenuedot.app/docs/api/sdk-endpoints.md#v1error). - **401**: Unknown API key. Returns [V1Error](https://revenuedot.app/docs/api/sdk-endpoints.md#v1error). ### Start a Web Billing checkout (not available) `POST /rcbilling/v1/checkout/start` · Auth: public app key **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/rcbilling/v1/checkout/start" -H "Authorization: Bearer $PUBLIC_KEY" ``` **Responses** - **400**: Not available. Returns [V1Error](https://revenuedot.app/docs/api/sdk-endpoints.md#v1error). - **401**: Unknown API key. Returns [V1Error](https://revenuedot.app/docs/api/sdk-endpoints.md#v1error). ### Web Billing checkout status `GET /rcbilling/v1/checkout/{operation_session_id}` · Auth: public app key No checkout session exists: 400 with code 7877. **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `operation_session_id` | string | yes | | **Example request** ```bash curl -s "$REVENUEDOT_URL/rcbilling/v1/checkout/$OPERATION_SESSION_ID" -H "Authorization: Bearer $PUBLIC_KEY" ``` **Responses** - **400**: No such session. Returns [V1Error](https://revenuedot.app/docs/api/sdk-endpoints.md#v1error). - **401**: Unknown API key. Returns [V1Error](https://revenuedot.app/docs/api/sdk-endpoints.md#v1error). ### Refresh Web Billing checkout pricing `PATCH /rcbilling/v1/checkout/{operation_session_id}` · Auth: public app key **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `operation_session_id` | string | yes | | **Example request** ```bash curl -s -X PATCH "$REVENUEDOT_URL/rcbilling/v1/checkout/$OPERATION_SESSION_ID" -H "Authorization: Bearer $PUBLIC_KEY" ``` **Responses** - **400**: No such session. Returns [V1Error](https://revenuedot.app/docs/api/sdk-endpoints.md#v1error). - **401**: Unknown API key. Returns [V1Error](https://revenuedot.app/docs/api/sdk-endpoints.md#v1error). ### Complete a Web Billing checkout `POST /rcbilling/v1/checkout/{operation_session_id}/complete` · Auth: public app key **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `operation_session_id` | string | yes | | **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/rcbilling/v1/checkout/$OPERATION_SESSION_ID/complete" -H "Authorization: Bearer $PUBLIC_KEY" ``` **Responses** - **400**: No such session. Returns [V1Error](https://revenuedot.app/docs/api/sdk-endpoints.md#v1error). - **401**: Unknown API key. Returns [V1Error](https://revenuedot.app/docs/api/sdk-endpoints.md#v1error). ### Web checkout branding `GET /rcbilling/v1/branding` · Auth: public app key purchases-js with an `rcb_` key loads it before a checkout: the app's name and the SDK's default look. **Example request** ```bash curl -s "$REVENUEDOT_URL/rcbilling/v1/branding" -H "Authorization: Bearer $PUBLIC_KEY" ``` **Responses** - **200**: Branding. - **401**: Unknown API key. Returns [V1Error](https://revenuedot.app/docs/api/sdk-endpoints.md#v1error). Example 200 response: ```json { "id": "appvnrm0a5h", "app_name": "Scanner Web", "app_icon": null, "app_icon_webp": null, "app_wordmark": null, "app_wordmark_webp": null, "appearance": null, "support_email": null, "gateway_tax_collection_enabled": false, "brand_font_config": null } ``` ## Store notifications Where App Store Connect and Google Pub/Sub send server notifications. ### App Store Server Notifications v2 `POST /v1/notifications/apple/{app_id}` · Auth: none Set this URL (shown on the app's page in the dashboard) as the Production and Sandbox Server URL in App Store Connect, with version 2 notifications. RevenueDot verifies Apple's signature and the bundle id, stores the raw body, copies it to `notification_forward_url` when set, and applies it. - **200:** handled, including notifications about purchases this server has not seen (stored; applied only with `track_new_purchases`). - **400:** the payload cannot be verified or belongs to another app. App Store Connect shows it as failed. - **404:** no App Store app with this id. - **500:** RevenueDot failed; Apple retries. **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `app_id` | string | yes | App id (app...). | **Request body** (`application/json`) | Field | Type | Required | Description | |---|---|---|---| | `signedPayload` | string | yes | Apple's signed JWS. | **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/v1/notifications/apple/$APP_ID" ``` **Responses** - **200**: Handled. - **400**: Unverifiable. - **404**: Unknown app. - **500**: Failed; Apple retries. Example 200 response: ```json { "ok": true } ``` ### Google Play real-time developer notifications (Pub/Sub push) `POST /v1/notifications/google/{app_id}` · Auth: Pub/Sub push token (optional) Set this URL as the endpoint of a Pub/Sub **push** subscription on the topic Google Play publishes to. When the app's `pubsub_audience` credential is set, the push must carry a Google-signed OIDC token for that audience (and for `pubsub_service_account` when set). Each message is stored once (by message id), forwarded when `notification_forward_url` is set, and applied by reading the purchase from the Play Developer API. - **200:** handled, a duplicate, ignored (another package, not a developer notification) or an invalid token that can never succeed. - **400:** not a Pub/Sub push body. **401:** bad push token. **404:** no Google Play app with this id. - **500 or 503:** a temporary failure; Pub/Sub redelivers. **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `app_id` | string | yes | App id (app...). | **Request body** (`application/json`) | Field | Type | Required | Description | |---|---|---|---| | `message` | object | yes | | | `message.data` | string | no | Base64 JSON developer notification. | | `message.messageId` | string | no | | | `message.publishTime` | string | no | | | `subscription` | string | no | | **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/v1/notifications/google/$APP_ID" ``` **Responses** - **200**: Handled. - **400**: Not a push body. Returns [V1Error](https://revenuedot.app/docs/api/sdk-endpoints.md#v1error). - **401**: Bad push token. Returns [V1Error](https://revenuedot.app/docs/api/sdk-endpoints.md#v1error). - **404**: Unknown app. Returns [V1Error](https://revenuedot.app/docs/api/sdk-endpoints.md#v1error). - **500**: Temporary failure; Pub/Sub retries. Returns [V1Error](https://revenuedot.app/docs/api/sdk-endpoints.md#v1error). - **503**: Google's signing keys could not be loaded. Returns [V1Error](https://revenuedot.app/docs/api/sdk-endpoints.md#v1error). Example 200 response: ```json { "status": "processed" } ``` ## Response signing Trusted Entitlements: the public key responses are signed with. ### Public key for response signatures `GET /.well-known/revenuedot-signing-key` · Auth: none The Ed25519 root public key this server signs SDK responses with (Trusted Entitlements). Pin this key in SDK builds that verify responses. The server signs only when `REVENUEDOT_SIGNING_KEY` is set; otherwise this answers 404. See [Trusted Entitlements](https://revenuedot.app/docs/guides/trusted-entitlements.md). **Example request** ```bash curl -s "$REVENUEDOT_URL/.well-known/revenuedot-signing-key" ``` **Responses** - **200**: The key. - **404**: Signing is off. Returns [V1Error](https://revenuedot.app/docs/api/sdk-endpoints.md#v1error). Example 200 response: ```json { "algorithm": "Ed25519", "public_key": "ZzwPxGlon0E8ErpDh9QAH0Jh6+E6D6qufvTSetXZY9Y=", "encoding": "base64", "header": "X-Signature", "docs": "https://revenuedot.app/docs" } ``` ## Objects The shapes the operations above send and return. ### CustomerInfo | Field | Type | Required | Description | |---|---|---|---| | `request_date` | string | yes | Server time of the response. ISO 8601 in UTC, whole seconds. | | `request_date_ms` | integer | yes | Server time of the response. Epoch milliseconds. | | `subscriber` | object | yes | | | `subscriber.entitlements` | object | yes | Entitlements the customer has now or had, keyed by lookup key. Check `expires_date` (or use the SDK's `isActive`). | | `subscriber.first_seen` | string | yes | When the customer was first seen. ISO 8601 in UTC, whole seconds. | | `subscriber.last_seen` | string | yes | When the customer was last seen. ISO 8601 in UTC, whole seconds. | | `subscriber.management_url` | string or null | no | Always null today. | | `subscriber.non_subscriptions` | object | yes | One-time purchases by product id, oldest first. | | `subscriber.original_app_user_id` | string | yes | The customer's first app user id. | | `subscriber.original_application_version` | string or null | no | Always null today. | | `subscriber.original_purchase_date` | string or null | no | Earliest purchase. ISO 8601 in UTC, whole seconds (for example 2026-10-30T20:41:54Z), or null. | | `subscriber.other_purchases` | object | no | Always empty. | | `subscriber.subscriber_attributes` | object | no | Only in answers to secret-key requests. | | `subscriber.subscriptions` | object | yes | Latest subscription per product id. | ### EntitlementInfo | Field | Type | Required | Description | |---|---|---|---| | `expires_date` | string or null | yes | When access ends. Null for lifetime access. ISO 8601 in UTC, whole seconds (for example 2026-10-30T20:41:54Z), or null. | | `grace_period_expires_date` | string or null | yes | End of the billing grace period, when the store grants one. ISO 8601 in UTC, whole seconds (for example 2026-10-30T20:41:54Z), or null. | | `product_identifier` | string | yes | Store product id that gives this access. | | `product_plan_identifier` | string | no | Google Play base plan id, only when there is one. | | `purchase_date` | string | yes | Start of the current period. ISO 8601 in UTC, whole seconds. | ### NonSubscriptionInfo | Field | Type | Required | Description | |---|---|---|---| | `display_name` | string or null | no | | | `id` | string | yes | RevenueDot purchase id. | | `is_sandbox` | boolean | yes | | | `original_purchase_date` | string or null | no | Purchase time. ISO 8601 in UTC, whole seconds (for example 2026-10-30T20:41:54Z), or null. | | `purchase_date` | string or null | yes | Purchase time. ISO 8601 in UTC, whole seconds (for example 2026-10-30T20:41:54Z), or null. | | `store` | string | yes | | | `store_transaction_id` | string | yes | Store transaction id. | | `price` | object | no | | | `price.amount` | number | yes | Price in the purchase currency. | | `price.currency` | string | yes | ISO 4217 currency code. | ### Offerings | Field | Type | Required | Description | |---|---|---|---| | `current_offering_id` | string or null | yes | Lookup key of the current offering, or the customer's override. | | `offerings` | array of object | yes | | | `offerings[].description` | string | yes | Offering display name. | | `offerings[].identifier` | string | yes | Offering lookup key. | | `offerings[].metadata` | object or null | yes | Your JSON metadata. | | `offerings[].packages` | array of object | yes | | | `offerings[].packages[].identifier` | string | yes | Package lookup key, for example $rc_monthly. | | `offerings[].packages[].platform_product_identifier` | string | yes | Store product id for the calling app. | | `offerings[].packages[].platform_product_plan_identifier` | string | no | Google Play base plan id, when the product is `subscription:base-plan`. | ### ReceiptResponse | Field | Type | Required | Description | |---|---|---|---| | `request_date` | string | yes | Server time of the response. ISO 8601 in UTC, whole seconds. | | `request_date_ms` | integer | yes | Server time of the response. Epoch milliseconds. | | `subscriber` | object | yes | | | `subscriber.entitlements` | object | yes | Entitlements the customer has now or had, keyed by lookup key. Check `expires_date` (or use the SDK's `isActive`). | | `subscriber.first_seen` | string | yes | When the customer was first seen. ISO 8601 in UTC, whole seconds. | | `subscriber.last_seen` | string | yes | When the customer was last seen. ISO 8601 in UTC, whole seconds. | | `subscriber.management_url` | string or null | no | Always null today. | | `subscriber.non_subscriptions` | object | yes | One-time purchases by product id, oldest first. | | `subscriber.original_app_user_id` | string | yes | The customer's first app user id. | | `subscriber.original_application_version` | string or null | no | Always null today. | | `subscriber.original_purchase_date` | string or null | no | Earliest purchase. ISO 8601 in UTC, whole seconds (for example 2026-10-30T20:41:54Z), or null. | | `subscriber.other_purchases` | object | no | Always empty. | | `subscriber.subscriber_attributes` | object | no | Only in answers to secret-key requests. | | `subscriber.subscriptions` | object | yes | Latest subscription per product id. | | `purchased_products` | object | no | One entry per purchase the receipt contained. | ### SubscriptionInfo | Field | Type | Required | Description | |---|---|---|---| | `auto_resume_date` | string or null | no | When a paused Google Play subscription resumes. ISO 8601 in UTC, whole seconds (for example 2026-10-30T20:41:54Z), or null. | | `billing_issues_detected_at` | string or null | no | When the latest renewal failed. ISO 8601 in UTC, whole seconds (for example 2026-10-30T20:41:54Z), or null. | | `display_name` | string or null | no | Product display name. | | `expires_date` | string or null | yes | End of the current period. ISO 8601 in UTC, whole seconds (for example 2026-10-30T20:41:54Z), or null. | | `grace_period_expires_date` | string or null | no | End of the billing grace period. ISO 8601 in UTC, whole seconds (for example 2026-10-30T20:41:54Z), or null. | | `is_sandbox` | boolean | yes | True for sandbox and Test Store purchases. | | `management_url` | string or null | no | Always null today. | | `original_purchase_date` | string or null | no | Start of the subscription. ISO 8601 in UTC, whole seconds (for example 2026-10-30T20:41:54Z), or null. | | `ownership_type` | `PURCHASED`, `FAMILY_SHARED` | no | | | `period_type` | `normal`, `trial`, `intro`, `promotional`, `prepaid` | yes | | | `purchase_date` | string or null | yes | Start of the current period. ISO 8601 in UTC, whole seconds (for example 2026-10-30T20:41:54Z), or null. | | `refunded_at` | string or null | no | When the store refunded it. ISO 8601 in UTC, whole seconds (for example 2026-10-30T20:41:54Z), or null. | | `store` | `app_store`, `mac_app_store`, `play_store`, `amazon`, `stripe`, `rc_billing`, `promotional`, `test_store`, `paddle`, `roku`, `external` | yes | | | `store_transaction_id` | string or null | no | Latest store transaction id (Apple), order id (Google) or Test Store token. | | `unsubscribe_detected_at` | string or null | no | When auto-renew was turned off. ISO 8601 in UTC, whole seconds (for example 2026-10-30T20:41:54Z), or null. | | `product_plan_identifier` | string | no | Google Play base plan id, only when there is one. | | `price` | object | no | | | `price.amount` | number | yes | Price in the purchase currency. | | `price.currency` | string | yes | ISO 4217 currency code. | ### V1Error | Field | Type | Required | Description | |---|---|---|---| | `code` | integer | yes | RevenueCat-compatible backend error code. See the error table. | | `message` | string | yes | What went wrong. | | `attribute_errors` | array of object | no | Only for 7263. | | `attribute_errors[].key_name` | string | no | | | `attribute_errors[].message` | string | no | | ## Related - [API overview](https://revenuedot.app/docs/api.md) - [Authentication](https://revenuedot.app/docs/api/authentication.md) - [Errors](https://revenuedot.app/docs/api/errors.md) - [OpenAPI document](https://revenuedot.app/docs/api/openapi.yaml) --- # What can I do with REST API v1? Source: https://revenuedot.app/docs/api/rest-v1.md Description: REST API v1 on RevenueDot: delete customers, grant promotional access, override offerings and run store actions with a secret key. REST API v1 uses the same `/v1/subscribers` paths as the SDK endpoints, with a **secret key** (`sk_...`). Customer info answers include `subscriber_attributes`. `GET /v1/subscribers/{app_user_id}` and `POST /v1/receipts` also accept a secret key; they are listed on [SDK endpoints](https://revenuedot.app/docs/api/sdk-endpoints.md). With a secret key, send `X-Platform` (ios, android ...) to `POST /v1/receipts` so RevenueDot knows the app. Base URL: your server, for example `http://localhost:8787` or `https://revenuedot.example.com`. The examples read `REVENUEDOT_URL`, `PUBLIC_KEY`, `SECRET_KEY` and `PROJECT_ID` from your shell. ## Operations on this page (10) - **Customers (v1)**: [Delete a customer](https://revenuedot.app/docs/api/rest-v1.md#delete-a-customer) - **Promotional entitlements (v1)**: [Grant promotional access](https://revenuedot.app/docs/api/rest-v1.md#grant-promotional-access), [Revoke promotional access](https://revenuedot.app/docs/api/rest-v1.md#revoke-promotional-access) - **Offering overrides (v1)**: [Show a customer another offering](https://revenuedot.app/docs/api/rest-v1.md#show-a-customer-another-offering), [Remove a customer's offering override](https://revenuedot.app/docs/api/rest-v1.md#remove-a-customers-offering-override) - **Store actions (v1)**: [Refund and revoke a Google Play subscription](https://revenuedot.app/docs/api/rest-v1.md#refund-and-revoke-a-google-play-subscription), [Defer a Google Play renewal](https://revenuedot.app/docs/api/rest-v1.md#defer-a-google-play-renewal), [Refund a Google Play order](https://revenuedot.app/docs/api/rest-v1.md#refund-a-google-play-order), [Cancel a Google Play subscription](https://revenuedot.app/docs/api/rest-v1.md#cancel-a-google-play-subscription), [Extend an App Store subscription](https://revenuedot.app/docs/api/rest-v1.md#extend-an-app-store-subscription) ## Customers (v1) Secret-key customer operations. ### Delete a customer `DELETE /v1/subscribers/{app_user_id}` · Auth: secret key Deletes the customer with its aliases, attributes, purchases and events. Cannot be undone. **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `app_user_id` | string | yes | App user id, URL-encoded (anonymous ids look like `$RCAnonymousID:...`). | **Example request** ```bash curl -s -X DELETE "$REVENUEDOT_URL/v1/subscribers/user_1" -H "Authorization: Bearer $SECRET_KEY" ``` **Responses** - **200**: Deleted. - **401**: Unknown API key. Returns [V1Error](https://revenuedot.app/docs/api/rest-v1.md#v1error). - **403**: A public app key was used for a secret-key endpoint. Returns [V1Error](https://revenuedot.app/docs/api/rest-v1.md#v1error). - **404**: Not found. Returns [V1Error](https://revenuedot.app/docs/api/rest-v1.md#v1error). Example 200 response: ```json { "app_user_id": "user_1" } ``` ## Promotional entitlements (v1) Grant and revoke access without a purchase. ### Grant promotional access `POST /v1/subscribers/{app_user_id}/entitlements/{entitlement_identifier}/promotional` · Auth: secret key Gives the customer the entitlement until `end_time_ms`, or for a `duration`. Creates the customer when needed. A grant whose end is within 2 hours of an existing promotional grant for the same entitlement is a duplicate and changes nothing. **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `app_user_id` | string | yes | App user id, URL-encoded (anonymous ids look like `$RCAnonymousID:...`). | | `entitlement_identifier` | string | yes | Entitlement lookup key, for example `pro`. | **Request body** (`application/json`) | Field | Type | Required | Description | |---|---|---|---| | `end_time_ms` | integer | no | When access ends, epoch milliseconds. Preferred. | | `duration` | `daily`, `three_day`, `weekly`, `two_week`, `monthly`, `two_month`, `three_month`, `six_month`, `yearly`, `lifetime` | no | Deprecated alternative to end_time_ms. | | `start_time_ms` | integer | no | Start for `duration`. Default now. | **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/v1/subscribers/user_1/entitlements/pro/promotional" -H "Authorization: Bearer $SECRET_KEY" \ -H "Content-Type: application/json" -d '{"duration":"weekly"}' ``` **Responses** - **200**: Customer info with the grant (store `promotional`). Returns [CustomerInfo](https://revenuedot.app/docs/api/rest-v1.md#customerinfo). - **400**: Bad request. For receipts, a 4xx tells the SDK the purchase can never be accepted, so it finishes the transaction. Returns [V1Error](https://revenuedot.app/docs/api/rest-v1.md#v1error). - **401**: Unknown API key. Returns [V1Error](https://revenuedot.app/docs/api/rest-v1.md#v1error). - **403**: A public app key was used for a secret-key endpoint. Returns [V1Error](https://revenuedot.app/docs/api/rest-v1.md#v1error). - **404**: Not found. Returns [V1Error](https://revenuedot.app/docs/api/rest-v1.md#v1error). Example 200 response: ```json { "request_date": "2026-09-30T20:41:54Z", "request_date_ms": 1790800914034, "subscriber": { "entitlements": { "pro": { "expires_date": "2026-10-30T20:41:54Z", "grace_period_expires_date": null, "product_identifier": "pro_monthly", "purchase_date": "2026-09-30T20:41:54Z" } }, "first_seen": "2026-09-30T20:41:54Z", "last_seen": "2026-09-30T20:41:54Z", "management_url": null, "non_subscriptions": {}, "original_app_user_id": "user_1", "original_application_version": null, "original_purchase_date": "2026-09-30T20:41:54Z", "other_purchases": {}, "subscriptions": { "pro_monthly": { "auto_resume_date": null, "billing_issues_detected_at": null, "display_name": null, "expires_date": "2026-10-30T20:41:54Z", "grace_period_expires_date": null, "is_sandbox": true, "management_url": null, "original_purchase_date": "2026-09-30T20:41:54Z", "ownership_type": "PURCHASED", "period_type": "normal", "purchase_date": "2026-09-30T20:41:54Z", "refunded_at": null, "store": "test_store", "store_transaction_id": "test_1790800914000_quickstart", "unsubscribe_detected_at": null, "price": { "amount": 9.99, "currency": "USD" } } } } } ``` ### Revoke promotional access `POST /v1/subscribers/{app_user_id}/entitlements/{entitlement_identifier}/revoke_promotionals` · Auth: secret key Ends every active promotional grant of this entitlement now. **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `app_user_id` | string | yes | App user id, URL-encoded (anonymous ids look like `$RCAnonymousID:...`). | | `entitlement_identifier` | string | yes | | **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/v1/subscribers/user_1/entitlements/pro/revoke_promotionals" -H "Authorization: Bearer $SECRET_KEY" ``` **Responses** - **200**: Customer info. Returns [CustomerInfo](https://revenuedot.app/docs/api/rest-v1.md#customerinfo). - **401**: Unknown API key. Returns [V1Error](https://revenuedot.app/docs/api/rest-v1.md#v1error). - **403**: A public app key was used for a secret-key endpoint. Returns [V1Error](https://revenuedot.app/docs/api/rest-v1.md#v1error). - **404**: Not found. Returns [V1Error](https://revenuedot.app/docs/api/rest-v1.md#v1error). Example 200 response: ```json { "request_date": "2026-09-30T20:41:54Z", "request_date_ms": 1790800914034, "subscriber": { "entitlements": { "pro": { "expires_date": "2026-10-30T20:41:54Z", "grace_period_expires_date": null, "product_identifier": "pro_monthly", "purchase_date": "2026-09-30T20:41:54Z" } }, "first_seen": "2026-09-30T20:41:54Z", "last_seen": "2026-09-30T20:41:54Z", "management_url": null, "non_subscriptions": {}, "original_app_user_id": "user_1", "original_application_version": null, "original_purchase_date": "2026-09-30T20:41:54Z", "other_purchases": {}, "subscriptions": { "pro_monthly": { "auto_resume_date": null, "billing_issues_detected_at": null, "display_name": null, "expires_date": "2026-10-30T20:41:54Z", "grace_period_expires_date": null, "is_sandbox": true, "management_url": null, "original_purchase_date": "2026-09-30T20:41:54Z", "ownership_type": "PURCHASED", "period_type": "normal", "purchase_date": "2026-09-30T20:41:54Z", "refunded_at": null, "store": "test_store", "store_transaction_id": "test_1790800914000_quickstart", "unsubscribe_detected_at": null, "price": { "amount": 9.99, "currency": "USD" } } } } } ``` ## Offering overrides (v1) Show one customer a different offering. ### Show a customer another offering `POST /v1/subscribers/{app_user_id}/offerings/{offering_identifier}/override` · Auth: secret key The customer's `current_offering_id` becomes this offering. **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `app_user_id` | string | yes | App user id, URL-encoded (anonymous ids look like `$RCAnonymousID:...`). | | `offering_identifier` | string | yes | Offering id (ofrng...) or lookup key. | **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/v1/subscribers/user_1/offerings/default/override" -H "Authorization: Bearer $SECRET_KEY" ``` **Responses** - **200**: Customer info. Returns [CustomerInfo](https://revenuedot.app/docs/api/rest-v1.md#customerinfo). - **401**: Unknown API key. Returns [V1Error](https://revenuedot.app/docs/api/rest-v1.md#v1error). - **403**: A public app key was used for a secret-key endpoint. Returns [V1Error](https://revenuedot.app/docs/api/rest-v1.md#v1error). - **404**: Not found. Returns [V1Error](https://revenuedot.app/docs/api/rest-v1.md#v1error). Example 200 response: ```json { "request_date": "2026-09-30T20:41:54Z", "request_date_ms": 1790800914034, "subscriber": { "entitlements": { "pro": { "expires_date": "2026-10-30T20:41:54Z", "grace_period_expires_date": null, "product_identifier": "pro_monthly", "purchase_date": "2026-09-30T20:41:54Z" } }, "first_seen": "2026-09-30T20:41:54Z", "last_seen": "2026-09-30T20:41:54Z", "management_url": null, "non_subscriptions": {}, "original_app_user_id": "user_1", "original_application_version": null, "original_purchase_date": "2026-09-30T20:41:54Z", "other_purchases": {}, "subscriptions": { "pro_monthly": { "auto_resume_date": null, "billing_issues_detected_at": null, "display_name": null, "expires_date": "2026-10-30T20:41:54Z", "grace_period_expires_date": null, "is_sandbox": true, "management_url": null, "original_purchase_date": "2026-09-30T20:41:54Z", "ownership_type": "PURCHASED", "period_type": "normal", "purchase_date": "2026-09-30T20:41:54Z", "refunded_at": null, "store": "test_store", "store_transaction_id": "test_1790800914000_quickstart", "unsubscribe_detected_at": null, "price": { "amount": 9.99, "currency": "USD" } } } } } ``` ### Remove a customer's offering override `DELETE /v1/subscribers/{app_user_id}/offerings/override` · Auth: secret key **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `app_user_id` | string | yes | App user id, URL-encoded (anonymous ids look like `$RCAnonymousID:...`). | **Example request** ```bash curl -s -X DELETE "$REVENUEDOT_URL/v1/subscribers/user_1/offerings/override" -H "Authorization: Bearer $SECRET_KEY" ``` **Responses** - **200**: Customer info. Returns [CustomerInfo](https://revenuedot.app/docs/api/rest-v1.md#customerinfo). - **401**: Unknown API key. Returns [V1Error](https://revenuedot.app/docs/api/rest-v1.md#v1error). - **403**: A public app key was used for a secret-key endpoint. Returns [V1Error](https://revenuedot.app/docs/api/rest-v1.md#v1error). - **404**: Not found. Returns [V1Error](https://revenuedot.app/docs/api/rest-v1.md#v1error). Example 200 response: ```json { "request_date": "2026-09-30T20:41:54Z", "request_date_ms": 1790800914034, "subscriber": { "entitlements": { "pro": { "expires_date": "2026-10-30T20:41:54Z", "grace_period_expires_date": null, "product_identifier": "pro_monthly", "purchase_date": "2026-09-30T20:41:54Z" } }, "first_seen": "2026-09-30T20:41:54Z", "last_seen": "2026-09-30T20:41:54Z", "management_url": null, "non_subscriptions": {}, "original_app_user_id": "user_1", "original_application_version": null, "original_purchase_date": "2026-09-30T20:41:54Z", "other_purchases": {}, "subscriptions": { "pro_monthly": { "auto_resume_date": null, "billing_issues_detected_at": null, "display_name": null, "expires_date": "2026-10-30T20:41:54Z", "grace_period_expires_date": null, "is_sandbox": true, "management_url": null, "original_purchase_date": "2026-09-30T20:41:54Z", "ownership_type": "PURCHASED", "period_type": "normal", "purchase_date": "2026-09-30T20:41:54Z", "refunded_at": null, "store": "test_store", "store_transaction_id": "test_1790800914000_quickstart", "unsubscribe_detected_at": null, "price": { "amount": 9.99, "currency": "USD" } } } } } ``` ## Store actions (v1) Refund, revoke, cancel, defer and extend through the store that sold the subscription. ### Refund and revoke a Google Play subscription `POST /v1/subscribers/{app_user_id}/subscriptions/{product_identifier}/revoke` · Auth: secret key Google Play only: refunds the latest payment and ends access now. Other stores answer 400 with code 7000. **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `app_user_id` | string | yes | App user id, URL-encoded (anonymous ids look like `$RCAnonymousID:...`). | | `product_identifier` | string | yes | Store product id of the subscription. | **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/v1/subscribers/user_1/subscriptions/$PRODUCT_IDENTIFIER/revoke" -H "Authorization: Bearer $SECRET_KEY" ``` **Responses** - **200**: Customer info. Returns [CustomerInfo](https://revenuedot.app/docs/api/rest-v1.md#customerinfo). - **400**: Bad request. For receipts, a 4xx tells the SDK the purchase can never be accepted, so it finishes the transaction. Returns [V1Error](https://revenuedot.app/docs/api/rest-v1.md#v1error). - **401**: Unknown API key. Returns [V1Error](https://revenuedot.app/docs/api/rest-v1.md#v1error). - **403**: A public app key was used for a secret-key endpoint. Returns [V1Error](https://revenuedot.app/docs/api/rest-v1.md#v1error). - **404**: Not found. Returns [V1Error](https://revenuedot.app/docs/api/rest-v1.md#v1error). - **503**: The store could not be reached. Retry later. Returns [V1Error](https://revenuedot.app/docs/api/rest-v1.md#v1error). Example 200 response: ```json { "request_date": "2026-09-30T20:41:54Z", "request_date_ms": 1790800914034, "subscriber": { "entitlements": { "pro": { "expires_date": "2026-10-30T20:41:54Z", "grace_period_expires_date": null, "product_identifier": "pro_monthly", "purchase_date": "2026-09-30T20:41:54Z" } }, "first_seen": "2026-09-30T20:41:54Z", "last_seen": "2026-09-30T20:41:54Z", "management_url": null, "non_subscriptions": {}, "original_app_user_id": "user_1", "original_application_version": null, "original_purchase_date": "2026-09-30T20:41:54Z", "other_purchases": {}, "subscriptions": { "pro_monthly": { "auto_resume_date": null, "billing_issues_detected_at": null, "display_name": null, "expires_date": "2026-10-30T20:41:54Z", "grace_period_expires_date": null, "is_sandbox": true, "management_url": null, "original_purchase_date": "2026-09-30T20:41:54Z", "ownership_type": "PURCHASED", "period_type": "normal", "purchase_date": "2026-09-30T20:41:54Z", "refunded_at": null, "store": "test_store", "store_transaction_id": "test_1790800914000_quickstart", "unsubscribe_detected_at": null, "price": { "amount": 9.99, "currency": "USD" } } } } } ``` ### Defer a Google Play renewal `POST /v1/subscribers/{app_user_id}/subscriptions/{product_identifier}/defer` · Auth: secret key Google Play only: moves the next renewal date. Send `expiry_time_ms` or `extend_by_days`. Use extend for App Store subscriptions. **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `app_user_id` | string | yes | App user id, URL-encoded (anonymous ids look like `$RCAnonymousID:...`). | | `product_identifier` | string | yes | | **Request body** (`application/json`) | Field | Type | Required | Description | |---|---|---|---| | `expiry_time_ms` | integer | no | New expiry, epoch milliseconds; later than the current one. | | `extend_by_days` | integer | no | Days to add, 1 to 365. | **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/v1/subscribers/user_1/subscriptions/$PRODUCT_IDENTIFIER/defer" -H "Authorization: Bearer $SECRET_KEY" \ -H "Content-Type: application/json" -d '{"extend_by_days":7}' ``` **Responses** - **200**: Customer info. Returns [CustomerInfo](https://revenuedot.app/docs/api/rest-v1.md#customerinfo). - **400**: Bad request. For receipts, a 4xx tells the SDK the purchase can never be accepted, so it finishes the transaction. Returns [V1Error](https://revenuedot.app/docs/api/rest-v1.md#v1error). - **401**: Unknown API key. Returns [V1Error](https://revenuedot.app/docs/api/rest-v1.md#v1error). - **403**: A public app key was used for a secret-key endpoint. Returns [V1Error](https://revenuedot.app/docs/api/rest-v1.md#v1error). - **404**: Not found. Returns [V1Error](https://revenuedot.app/docs/api/rest-v1.md#v1error). - **503**: The store could not be reached. Retry later. Returns [V1Error](https://revenuedot.app/docs/api/rest-v1.md#v1error). Example 200 response: ```json { "request_date": "2026-09-30T20:41:54Z", "request_date_ms": 1790800914034, "subscriber": { "entitlements": { "pro": { "expires_date": "2026-10-30T20:41:54Z", "grace_period_expires_date": null, "product_identifier": "pro_monthly", "purchase_date": "2026-09-30T20:41:54Z" } }, "first_seen": "2026-09-30T20:41:54Z", "last_seen": "2026-09-30T20:41:54Z", "management_url": null, "non_subscriptions": {}, "original_app_user_id": "user_1", "original_application_version": null, "original_purchase_date": "2026-09-30T20:41:54Z", "other_purchases": {}, "subscriptions": { "pro_monthly": { "auto_resume_date": null, "billing_issues_detected_at": null, "display_name": null, "expires_date": "2026-10-30T20:41:54Z", "grace_period_expires_date": null, "is_sandbox": true, "management_url": null, "original_purchase_date": "2026-09-30T20:41:54Z", "ownership_type": "PURCHASED", "period_type": "normal", "purchase_date": "2026-09-30T20:41:54Z", "refunded_at": null, "store": "test_store", "store_transaction_id": "test_1790800914000_quickstart", "unsubscribe_detected_at": null, "price": { "amount": 9.99, "currency": "USD" } } } } } ``` ### Refund a Google Play order `POST /v1/subscribers/{app_user_id}/transactions/{store_transaction_identifier}/refund` · Auth: secret key Google Play only: refunds and revokes the order. **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `app_user_id` | string | yes | App user id, URL-encoded (anonymous ids look like `$RCAnonymousID:...`). | | `store_transaction_identifier` | string | yes | Google order id. | **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/v1/subscribers/user_1/transactions/$STORE_TRANSACTION_IDENTIFIER/refund" -H "Authorization: Bearer $SECRET_KEY" ``` **Responses** - **200**: Customer info. Returns [CustomerInfo](https://revenuedot.app/docs/api/rest-v1.md#customerinfo). - **400**: Bad request. For receipts, a 4xx tells the SDK the purchase can never be accepted, so it finishes the transaction. Returns [V1Error](https://revenuedot.app/docs/api/rest-v1.md#v1error). - **401**: Unknown API key. Returns [V1Error](https://revenuedot.app/docs/api/rest-v1.md#v1error). - **403**: A public app key was used for a secret-key endpoint. Returns [V1Error](https://revenuedot.app/docs/api/rest-v1.md#v1error). - **404**: Not found. Returns [V1Error](https://revenuedot.app/docs/api/rest-v1.md#v1error). - **503**: The store could not be reached. Retry later. Returns [V1Error](https://revenuedot.app/docs/api/rest-v1.md#v1error). Example 200 response: ```json { "request_date": "2026-09-30T20:41:54Z", "request_date_ms": 1790800914034, "subscriber": { "entitlements": { "pro": { "expires_date": "2026-10-30T20:41:54Z", "grace_period_expires_date": null, "product_identifier": "pro_monthly", "purchase_date": "2026-09-30T20:41:54Z" } }, "first_seen": "2026-09-30T20:41:54Z", "last_seen": "2026-09-30T20:41:54Z", "management_url": null, "non_subscriptions": {}, "original_app_user_id": "user_1", "original_application_version": null, "original_purchase_date": "2026-09-30T20:41:54Z", "other_purchases": {}, "subscriptions": { "pro_monthly": { "auto_resume_date": null, "billing_issues_detected_at": null, "display_name": null, "expires_date": "2026-10-30T20:41:54Z", "grace_period_expires_date": null, "is_sandbox": true, "management_url": null, "original_purchase_date": "2026-09-30T20:41:54Z", "ownership_type": "PURCHASED", "period_type": "normal", "purchase_date": "2026-09-30T20:41:54Z", "refunded_at": null, "store": "test_store", "store_transaction_id": "test_1790800914000_quickstart", "unsubscribe_detected_at": null, "price": { "amount": 9.99, "currency": "USD" } } } } } ``` ### Cancel a Google Play subscription `POST /v1/subscribers/{app_user_id}/subscriptions/{store_transaction_identifier}/cancel` · Auth: secret key Google Play only: turns auto-renew off; access continues to the end of the period. **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `app_user_id` | string | yes | App user id, URL-encoded (anonymous ids look like `$RCAnonymousID:...`). | | `store_transaction_identifier` | string | yes | Store transaction id of the subscription. | **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/v1/subscribers/user_1/subscriptions/$STORE_TRANSACTION_IDENTIFIER/cancel" -H "Authorization: Bearer $SECRET_KEY" ``` **Responses** - **200**: Customer info. Returns [CustomerInfo](https://revenuedot.app/docs/api/rest-v1.md#customerinfo). - **400**: Bad request. For receipts, a 4xx tells the SDK the purchase can never be accepted, so it finishes the transaction. Returns [V1Error](https://revenuedot.app/docs/api/rest-v1.md#v1error). - **401**: Unknown API key. Returns [V1Error](https://revenuedot.app/docs/api/rest-v1.md#v1error). - **403**: A public app key was used for a secret-key endpoint. Returns [V1Error](https://revenuedot.app/docs/api/rest-v1.md#v1error). - **404**: Not found. Returns [V1Error](https://revenuedot.app/docs/api/rest-v1.md#v1error). - **503**: The store could not be reached. Retry later. Returns [V1Error](https://revenuedot.app/docs/api/rest-v1.md#v1error). Example 200 response: ```json { "request_date": "2026-09-30T20:41:54Z", "request_date_ms": 1790800914034, "subscriber": { "entitlements": { "pro": { "expires_date": "2026-10-30T20:41:54Z", "grace_period_expires_date": null, "product_identifier": "pro_monthly", "purchase_date": "2026-09-30T20:41:54Z" } }, "first_seen": "2026-09-30T20:41:54Z", "last_seen": "2026-09-30T20:41:54Z", "management_url": null, "non_subscriptions": {}, "original_app_user_id": "user_1", "original_application_version": null, "original_purchase_date": "2026-09-30T20:41:54Z", "other_purchases": {}, "subscriptions": { "pro_monthly": { "auto_resume_date": null, "billing_issues_detected_at": null, "display_name": null, "expires_date": "2026-10-30T20:41:54Z", "grace_period_expires_date": null, "is_sandbox": true, "management_url": null, "original_purchase_date": "2026-09-30T20:41:54Z", "ownership_type": "PURCHASED", "period_type": "normal", "purchase_date": "2026-09-30T20:41:54Z", "refunded_at": null, "store": "test_store", "store_transaction_id": "test_1790800914000_quickstart", "unsubscribe_detected_at": null, "price": { "amount": 9.99, "currency": "USD" } } } } } ``` ### Extend an App Store subscription `POST /v1/subscribers/{app_user_id}/subscriptions/{store_transaction_identifier}/extend` · Auth: secret key App Store only (needs the app's in-app purchase key): Apple extends the renewal date. Use defer for Google Play. **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `app_user_id` | string | yes | App user id, URL-encoded (anonymous ids look like `$RCAnonymousID:...`). | | `store_transaction_identifier` | string | yes | | **Request body** (`application/json`) | Field | Type | Required | Description | |---|---|---|---| | `extend_by_days` | integer | no | 1 to 90. | | `extend_reason_code` | integer | no | Apple's reason code: 0 undeclared, 1 customer satisfaction, 2 other, 3 service issue or outage. | **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/v1/subscribers/user_1/subscriptions/$STORE_TRANSACTION_IDENTIFIER/extend" -H "Authorization: Bearer $SECRET_KEY" \ -H "Content-Type: application/json" -d '{"extend_by_days":7,"extend_reason_code":1}' ``` **Responses** - **200**: Customer info. Returns [CustomerInfo](https://revenuedot.app/docs/api/rest-v1.md#customerinfo). - **400**: Bad request. For receipts, a 4xx tells the SDK the purchase can never be accepted, so it finishes the transaction. Returns [V1Error](https://revenuedot.app/docs/api/rest-v1.md#v1error). - **401**: Unknown API key. Returns [V1Error](https://revenuedot.app/docs/api/rest-v1.md#v1error). - **403**: A public app key was used for a secret-key endpoint. Returns [V1Error](https://revenuedot.app/docs/api/rest-v1.md#v1error). - **404**: Not found. Returns [V1Error](https://revenuedot.app/docs/api/rest-v1.md#v1error). - **503**: The store could not be reached. Retry later. Returns [V1Error](https://revenuedot.app/docs/api/rest-v1.md#v1error). Example 200 response: ```json { "request_date": "2026-09-30T20:41:54Z", "request_date_ms": 1790800914034, "subscriber": { "entitlements": { "pro": { "expires_date": "2026-10-30T20:41:54Z", "grace_period_expires_date": null, "product_identifier": "pro_monthly", "purchase_date": "2026-09-30T20:41:54Z" } }, "first_seen": "2026-09-30T20:41:54Z", "last_seen": "2026-09-30T20:41:54Z", "management_url": null, "non_subscriptions": {}, "original_app_user_id": "user_1", "original_application_version": null, "original_purchase_date": "2026-09-30T20:41:54Z", "other_purchases": {}, "subscriptions": { "pro_monthly": { "auto_resume_date": null, "billing_issues_detected_at": null, "display_name": null, "expires_date": "2026-10-30T20:41:54Z", "grace_period_expires_date": null, "is_sandbox": true, "management_url": null, "original_purchase_date": "2026-09-30T20:41:54Z", "ownership_type": "PURCHASED", "period_type": "normal", "purchase_date": "2026-09-30T20:41:54Z", "refunded_at": null, "store": "test_store", "store_transaction_id": "test_1790800914000_quickstart", "unsubscribe_detected_at": null, "price": { "amount": 9.99, "currency": "USD" } } } } } ``` ## Objects The shapes the operations above send and return. ### CustomerInfo | Field | Type | Required | Description | |---|---|---|---| | `request_date` | string | yes | Server time of the response. ISO 8601 in UTC, whole seconds. | | `request_date_ms` | integer | yes | Server time of the response. Epoch milliseconds. | | `subscriber` | object | yes | | | `subscriber.entitlements` | object | yes | Entitlements the customer has now or had, keyed by lookup key. Check `expires_date` (or use the SDK's `isActive`). | | `subscriber.first_seen` | string | yes | When the customer was first seen. ISO 8601 in UTC, whole seconds. | | `subscriber.last_seen` | string | yes | When the customer was last seen. ISO 8601 in UTC, whole seconds. | | `subscriber.management_url` | string or null | no | Always null today. | | `subscriber.non_subscriptions` | object | yes | One-time purchases by product id, oldest first. | | `subscriber.original_app_user_id` | string | yes | The customer's first app user id. | | `subscriber.original_application_version` | string or null | no | Always null today. | | `subscriber.original_purchase_date` | string or null | no | Earliest purchase. ISO 8601 in UTC, whole seconds (for example 2026-10-30T20:41:54Z), or null. | | `subscriber.other_purchases` | object | no | Always empty. | | `subscriber.subscriber_attributes` | object | no | Only in answers to secret-key requests. | | `subscriber.subscriptions` | object | yes | Latest subscription per product id. | ### EntitlementInfo | Field | Type | Required | Description | |---|---|---|---| | `expires_date` | string or null | yes | When access ends. Null for lifetime access. ISO 8601 in UTC, whole seconds (for example 2026-10-30T20:41:54Z), or null. | | `grace_period_expires_date` | string or null | yes | End of the billing grace period, when the store grants one. ISO 8601 in UTC, whole seconds (for example 2026-10-30T20:41:54Z), or null. | | `product_identifier` | string | yes | Store product id that gives this access. | | `product_plan_identifier` | string | no | Google Play base plan id, only when there is one. | | `purchase_date` | string | yes | Start of the current period. ISO 8601 in UTC, whole seconds. | ### NonSubscriptionInfo | Field | Type | Required | Description | |---|---|---|---| | `display_name` | string or null | no | | | `id` | string | yes | RevenueDot purchase id. | | `is_sandbox` | boolean | yes | | | `original_purchase_date` | string or null | no | Purchase time. ISO 8601 in UTC, whole seconds (for example 2026-10-30T20:41:54Z), or null. | | `purchase_date` | string or null | yes | Purchase time. ISO 8601 in UTC, whole seconds (for example 2026-10-30T20:41:54Z), or null. | | `store` | string | yes | | | `store_transaction_id` | string | yes | Store transaction id. | | `price` | object | no | | | `price.amount` | number | yes | Price in the purchase currency. | | `price.currency` | string | yes | ISO 4217 currency code. | ### SubscriptionInfo | Field | Type | Required | Description | |---|---|---|---| | `auto_resume_date` | string or null | no | When a paused Google Play subscription resumes. ISO 8601 in UTC, whole seconds (for example 2026-10-30T20:41:54Z), or null. | | `billing_issues_detected_at` | string or null | no | When the latest renewal failed. ISO 8601 in UTC, whole seconds (for example 2026-10-30T20:41:54Z), or null. | | `display_name` | string or null | no | Product display name. | | `expires_date` | string or null | yes | End of the current period. ISO 8601 in UTC, whole seconds (for example 2026-10-30T20:41:54Z), or null. | | `grace_period_expires_date` | string or null | no | End of the billing grace period. ISO 8601 in UTC, whole seconds (for example 2026-10-30T20:41:54Z), or null. | | `is_sandbox` | boolean | yes | True for sandbox and Test Store purchases. | | `management_url` | string or null | no | Always null today. | | `original_purchase_date` | string or null | no | Start of the subscription. ISO 8601 in UTC, whole seconds (for example 2026-10-30T20:41:54Z), or null. | | `ownership_type` | `PURCHASED`, `FAMILY_SHARED` | no | | | `period_type` | `normal`, `trial`, `intro`, `promotional`, `prepaid` | yes | | | `purchase_date` | string or null | yes | Start of the current period. ISO 8601 in UTC, whole seconds (for example 2026-10-30T20:41:54Z), or null. | | `refunded_at` | string or null | no | When the store refunded it. ISO 8601 in UTC, whole seconds (for example 2026-10-30T20:41:54Z), or null. | | `store` | `app_store`, `mac_app_store`, `play_store`, `amazon`, `stripe`, `rc_billing`, `promotional`, `test_store`, `paddle`, `roku`, `external` | yes | | | `store_transaction_id` | string or null | no | Latest store transaction id (Apple), order id (Google) or Test Store token. | | `unsubscribe_detected_at` | string or null | no | When auto-renew was turned off. ISO 8601 in UTC, whole seconds (for example 2026-10-30T20:41:54Z), or null. | | `product_plan_identifier` | string | no | Google Play base plan id, only when there is one. | | `price` | object | no | | | `price.amount` | number | yes | Price in the purchase currency. | | `price.currency` | string | yes | ISO 4217 currency code. | ### V1Error | Field | Type | Required | Description | |---|---|---|---| | `code` | integer | yes | RevenueCat-compatible backend error code. See the error table. | | `message` | string | yes | What went wrong. | | `attribute_errors` | array of object | no | Only for 7263. | | `attribute_errors[].key_name` | string | no | | | `attribute_errors[].message` | string | no | | ## Related - [API overview](https://revenuedot.app/docs/api.md) - [Authentication](https://revenuedot.app/docs/api/authentication.md) - [Errors](https://revenuedot.app/docs/api/errors.md) - [OpenAPI document](https://revenuedot.app/docs/api/openapi.yaml) --- # What can I do with REST API v2? Source: https://revenuedot.app/docs/api/rest-v2.md Description: REST API v2 on RevenueDot: projects, apps, products, entitlements, offerings, packages, customers, subscriptions, purchases, metrics and webhooks. REST API v2 follows RevenueCat's v2 paths, objects, list envelope and error format, so existing scripts keep working when you change the base URL and key. Authenticate with a **secret key** (`Authorization: Bearer sk_...`) or the dashboard session cookie. Lists return `{ "object": "list", "items": [...], "next_page": ..., "url": ... }`; follow `next_page` to page. `limit` is 1 to 100 (default 20). RevenueDot-only endpoints are on [Extensions](https://revenuedot.app/docs/api/extensions.md). Base URL: your server, for example `http://localhost:8787` or `https://revenuedot.example.com`. The examples read `REVENUEDOT_URL`, `PUBLIC_KEY`, `SECRET_KEY` and `PROJECT_ID` from your shell. ## Operations on this page (73) - **Projects**: [List projects](https://revenuedot.app/docs/api/rest-v2.md#list-projects), [Create a project](https://revenuedot.app/docs/api/rest-v2.md#create-a-project) - **Apps**: [List apps](https://revenuedot.app/docs/api/rest-v2.md#list-apps), [Create an app](https://revenuedot.app/docs/api/rest-v2.md#create-an-app), [Get an app](https://revenuedot.app/docs/api/rest-v2.md#get-an-app), [Update an app and its store credentials](https://revenuedot.app/docs/api/rest-v2.md#update-an-app-and-its-store-credentials), [Delete an app](https://revenuedot.app/docs/api/rest-v2.md#delete-an-app), [Get an app's public SDK key](https://revenuedot.app/docs/api/rest-v2.md#get-an-apps-public-sdk-key) - **Products**: [List products](https://revenuedot.app/docs/api/rest-v2.md#list-products), [Create a product](https://revenuedot.app/docs/api/rest-v2.md#create-a-product), [Get a product](https://revenuedot.app/docs/api/rest-v2.md#get-a-product), [Update a product](https://revenuedot.app/docs/api/rest-v2.md#update-a-product), [Delete a product](https://revenuedot.app/docs/api/rest-v2.md#delete-a-product), [Archive a product](https://revenuedot.app/docs/api/rest-v2.md#archive-a-product), [Unarchive a product](https://revenuedot.app/docs/api/rest-v2.md#unarchive-a-product) - **Entitlements**: [List entitlements](https://revenuedot.app/docs/api/rest-v2.md#list-entitlements), [Create an entitlement](https://revenuedot.app/docs/api/rest-v2.md#create-an-entitlement), [Get an entitlement](https://revenuedot.app/docs/api/rest-v2.md#get-an-entitlement), [Rename an entitlement](https://revenuedot.app/docs/api/rest-v2.md#rename-an-entitlement), [Delete an entitlement](https://revenuedot.app/docs/api/rest-v2.md#delete-an-entitlement), [Archive an entitlement](https://revenuedot.app/docs/api/rest-v2.md#archive-an-entitlement), [Unarchive an entitlement](https://revenuedot.app/docs/api/rest-v2.md#unarchive-an-entitlement), [List an entitlement's products](https://revenuedot.app/docs/api/rest-v2.md#list-an-entitlements-products), [Attach products to an entitlement](https://revenuedot.app/docs/api/rest-v2.md#attach-products-to-an-entitlement), [Detach products from an entitlement](https://revenuedot.app/docs/api/rest-v2.md#detach-products-from-an-entitlement) - **Offerings**: [List offerings](https://revenuedot.app/docs/api/rest-v2.md#list-offerings), [Create an offering](https://revenuedot.app/docs/api/rest-v2.md#create-an-offering), [Get an offering](https://revenuedot.app/docs/api/rest-v2.md#get-an-offering), [Update an offering or make it current](https://revenuedot.app/docs/api/rest-v2.md#update-an-offering-or-make-it-current), [Delete an offering](https://revenuedot.app/docs/api/rest-v2.md#delete-an-offering), [Archive an offering](https://revenuedot.app/docs/api/rest-v2.md#archive-an-offering), [Unarchive an offering](https://revenuedot.app/docs/api/rest-v2.md#unarchive-an-offering) - **Packages**: [List an offering's packages](https://revenuedot.app/docs/api/rest-v2.md#list-an-offerings-packages), [Create a package](https://revenuedot.app/docs/api/rest-v2.md#create-a-package), [Get a package](https://revenuedot.app/docs/api/rest-v2.md#get-a-package), [Update a package](https://revenuedot.app/docs/api/rest-v2.md#update-a-package), [Delete a package](https://revenuedot.app/docs/api/rest-v2.md#delete-a-package), [List a package's products](https://revenuedot.app/docs/api/rest-v2.md#list-a-packages-products), [Attach products to a package](https://revenuedot.app/docs/api/rest-v2.md#attach-products-to-a-package), [Detach products from a package](https://revenuedot.app/docs/api/rest-v2.md#detach-products-from-a-package) - **Customers**: [List or search customers](https://revenuedot.app/docs/api/rest-v2.md#list-or-search-customers), [Create a customer](https://revenuedot.app/docs/api/rest-v2.md#create-a-customer), [Get a customer](https://revenuedot.app/docs/api/rest-v2.md#get-a-customer), [Delete a customer](https://revenuedot.app/docs/api/rest-v2.md#delete-a-customer), [List a customer's app user ids](https://revenuedot.app/docs/api/rest-v2.md#list-a-customers-app-user-ids), [List a customer's attributes](https://revenuedot.app/docs/api/rest-v2.md#list-a-customers-attributes), [Set a customer's attributes](https://revenuedot.app/docs/api/rest-v2.md#set-a-customers-attributes), [List a customer's active entitlements](https://revenuedot.app/docs/api/rest-v2.md#list-a-customers-active-entitlements), [List a customer's subscriptions](https://revenuedot.app/docs/api/rest-v2.md#list-a-customers-subscriptions), [List a customer's one-time purchases](https://revenuedot.app/docs/api/rest-v2.md#list-a-customers-one-time-purchases), [List a customer's events](https://revenuedot.app/docs/api/rest-v2.md#list-a-customers-events), [Grant an entitlement](https://revenuedot.app/docs/api/rest-v2.md#grant-an-entitlement), [Revoke a granted entitlement](https://revenuedot.app/docs/api/rest-v2.md#revoke-a-granted-entitlement), [Assign an offering to a customer](https://revenuedot.app/docs/api/rest-v2.md#assign-an-offering-to-a-customer) - **Subscriptions**: [Find subscriptions by store id](https://revenuedot.app/docs/api/rest-v2.md#find-subscriptions-by-store-id), [Get a subscription](https://revenuedot.app/docs/api/rest-v2.md#get-a-subscription), [List the entitlements a subscription unlocks](https://revenuedot.app/docs/api/rest-v2.md#list-the-entitlements-a-subscription-unlocks), [List a subscription's payments](https://revenuedot.app/docs/api/rest-v2.md#list-a-subscriptions-payments), [Cancel a subscription (Google Play)](https://revenuedot.app/docs/api/rest-v2.md#cancel-a-subscription-google-play), [Refund and revoke a subscription (Google Play)](https://revenuedot.app/docs/api/rest-v2.md#refund-and-revoke-a-subscription-google-play), [Extend a subscription](https://revenuedot.app/docs/api/rest-v2.md#extend-a-subscription), [Refund one payment of a subscription (Google Play)](https://revenuedot.app/docs/api/rest-v2.md#refund-one-payment-of-a-subscription-google-play) - **Purchases**: [Find one-time purchases by store id](https://revenuedot.app/docs/api/rest-v2.md#find-one-time-purchases-by-store-id), [Get a one-time purchase](https://revenuedot.app/docs/api/rest-v2.md#get-a-one-time-purchase), [List the entitlements a purchase unlocks](https://revenuedot.app/docs/api/rest-v2.md#list-the-entitlements-a-purchase-unlocks), [Refund a one-time purchase (Google Play)](https://revenuedot.app/docs/api/rest-v2.md#refund-a-one-time-purchase-google-play) - **Metrics**: [Overview metrics](https://revenuedot.app/docs/api/rest-v2.md#overview-metrics) - **Webhook integrations**: [List webhooks](https://revenuedot.app/docs/api/rest-v2.md#list-webhooks), [Create a webhook](https://revenuedot.app/docs/api/rest-v2.md#create-a-webhook), [Get a webhook](https://revenuedot.app/docs/api/rest-v2.md#get-a-webhook), [Update a webhook](https://revenuedot.app/docs/api/rest-v2.md#update-a-webhook), [Delete a webhook](https://revenuedot.app/docs/api/rest-v2.md#delete-a-webhook) - **Collaborators**: [List collaborators](https://revenuedot.app/docs/api/rest-v2.md#list-collaborators) ## Projects Projects hold apps, the catalog, customers and webhooks. ### List projects `GET /v2/projects` · Auth: secret key or dashboard session · Permissions: `project_configuration:projects:read` A secret key sees its own project. A dashboard session sees every project the user is a member of. **Query parameters** | Name | Type | Required | Description | |---|---|---|---| | `limit` | integer | no | Page size. Values outside 1-100 are clamped, not rejected. | | `starting_after` | string | no | Id of the last item of the previous page. Use `next_page` instead of building it. | **Example request** ```bash curl -s "$REVENUEDOT_URL/v2/projects" -H "Authorization: Bearer $SECRET_KEY" ``` **Responses** - **200**: A page of results. Returns a list of [Project](https://revenuedot.app/docs/api/rest-v2.md#project). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). ### Create a project `POST /v2/projects` · Auth: dashboard session · Permissions: `project_configuration:projects:read_write` Needs a dashboard session: a secret key belongs to one project and cannot create another. The caller becomes the project's admin. **Request body** (`application/json`) | Field | Type | Required | Description | |---|---|---|---| | `name` | string | yes | | **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/v2/projects" \ -H "Content-Type: application/json" -d '{"name":"Scanner"}' ``` **Responses** - **200**: The project. Returns [Project](https://revenuedot.app/docs/api/rest-v2.md#project). - **400**: The request is invalid. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). ## Apps One app per store, each with its public SDK key and store credentials. ### List apps `GET /v2/projects/{project_id}/apps` · Auth: secret key or dashboard session · Permissions: `project_configuration:apps:read` **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | **Query parameters** | Name | Type | Required | Description | |---|---|---|---| | `limit` | integer | no | Page size. Values outside 1-100 are clamped, not rejected. | | `starting_after` | string | no | Id of the last item of the previous page. Use `next_page` instead of building it. | **Example request** ```bash curl -s "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/apps" -H "Authorization: Bearer $SECRET_KEY" ``` **Responses** - **200**: A page of results. Returns a list of [App](https://revenuedot.app/docs/api/rest-v2.md#app). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). ### Create an app `POST /v2/projects/{project_id}/apps` · Auth: secret key or dashboard session · Permissions: `project_configuration:apps:read_write` One app per store. `app_store` and `mac_app_store` need `bundle_id`; `play_store` and `amazon` need `package_name`. The app gets a public SDK key with the store's prefix. Other fields in the store object are saved as store credentials (for example `subscription_private_key`, `subscription_key_id`, `subscription_key_issuer`, `play_service_account_credentials_json`). They are never returned. **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | **Request body** (`application/json`) | Field | Type | Required | Description | |---|---|---|---| | `name` | string | yes | | | `type` | `amazon`, `app_store`, `mac_app_store`, `play_store`, `stripe`, `rc_billing`, `roku`, `paddle`, `test_store` | yes | | | `app_store` | object | no | `bundle_id` plus optional credentials. | | `mac_app_store` | object | no | | | `play_store` | object | no | `package_name` plus optional credentials. | | `amazon` | object | no | | | `stripe` | object | no | | | `rc_billing` | object or null | no | | | `roku` | object or null | no | | | `paddle` | object or null | no | | **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/apps" -H "Authorization: Bearer $SECRET_KEY" \ -H "Content-Type: application/json" -d '{"name":"Scanner (iOS)","type":"app_store","app_store":{"bundle_id":"com.example.scanner"}}' ``` **Responses** - **201**: The app. Returns [App](https://revenuedot.app/docs/api/rest-v2.md#app). - **400**: The request is invalid. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). Example 201 response: ```json { "object": "app", "id": "appugfw01uy", "name": "Scanner (iOS)", "created_at": 1790801342594, "type": "app_store", "project_id": "proj18pzzkao", "custom_url_scheme": "rc-4d13549313", "app_store": { "bundle_id": "com.example.scanner", "app_store_connect_api_key_configured": false, "subscription_key_configured": false, "app_store_connect_vendor_number": null } } ``` ### Get an app `GET /v2/projects/{project_id}/apps/{app_id}` · Auth: secret key or dashboard session · Permissions: `project_configuration:apps:read` **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | | `app_id` | string | yes | App id (app...). | **Example request** ```bash curl -s "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/apps/$APP_ID" -H "Authorization: Bearer $SECRET_KEY" ``` **Responses** - **200**: The app. Returns [App](https://revenuedot.app/docs/api/rest-v2.md#app). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). ### Update an app and its store credentials `POST /v2/projects/{project_id}/apps/{app_id}` · Auth: secret key or dashboard session · Permissions: `project_configuration:apps:read_write` Send only the store object of the app's own type. A field set to null removes that credential; other values replace it. RevenueDot extensions in the store object: `notification_forward_url` (copy store notifications to another URL, for example RevenueCat during a dual run; null or "" turns it off), `track_new_purchases`, `allow_unsigned_receipts`, `xcode_certificate`, `app_apple_id`, `pubsub_audience`, `pubsub_service_account`. See [App Store setup](https://revenuedot.app/docs/guides/app-store.md) and [Google Play setup](https://revenuedot.app/docs/guides/google-play.md). **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | | `app_id` | string | yes | App id (app...). | **Request body** (`application/json`) | Field | Type | Required | Description | |---|---|---|---| | `name` | string | no | | | `app_store` | object | no | | | `mac_app_store` | object | no | | | `play_store` | object | no | | | `amazon` | object | no | | | `stripe` | object | no | | | `rc_billing` | object | no | | | `roku` | object | no | | | `paddle` | object | no | | **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/apps/$APP_ID" -H "Authorization: Bearer $SECRET_KEY" \ -H "Content-Type: application/json" -d '{"app_store":{"subscription_private_key":"-----BEGIN PRIVATE KEY-----\n…\n-----END PRIVATE KEY-----","subscription_key_id":"ABC123DEFG","subscription_key_issuer":"57246542-96fe-1a63-e053-0824d011072a"}}' ``` **Responses** - **200**: The app. Returns [App](https://revenuedot.app/docs/api/rest-v2.md#app). - **400**: The request is invalid. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). ### Delete an app `DELETE /v2/projects/{project_id}/apps/{app_id}` · Auth: secret key or dashboard session · Permissions: `project_configuration:apps:read_write` Deletes the app and its products. Purchase history stays. **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | | `app_id` | string | yes | App id (app...). | **Example request** ```bash curl -s -X DELETE "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/apps/$APP_ID" -H "Authorization: Bearer $SECRET_KEY" ``` **Responses** - **200**: Deleted. Returns [Deleted](https://revenuedot.app/docs/api/rest-v2.md#deleted). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). Example 200 response: ```json { "object": "app", "id": "…", "deleted_at": 1790801342625 } ``` ### Get an app's public SDK key `GET /v2/projects/{project_id}/apps/{app_id}/public_api_keys` · Auth: secret key or dashboard session · Permissions: `project_configuration:apps:read` **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | | `app_id` | string | yes | App id (app...). | **Example request** ```bash curl -s "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/apps/$APP_ID/public_api_keys" -H "Authorization: Bearer $SECRET_KEY" ``` **Responses** - **200**: One key. Returns a list of [PublicApiKey](https://revenuedot.app/docs/api/rest-v2.md#publicapikey). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). Example 200 response: ```json { "object": "list", "items": [ { "object": "public_api_key", "id": "pk_appvnrm0a5h", "key": "test_ea5120a23e7b9626f8eff225a627762c", "environment": "sandbox", "app_id": "appvnrm0a5h", "created_at": 1790800900758 } ], "next_page": null, "url": "/v2/projects/proj18pzzkao/apps/appvnrm0a5h/public_api_keys" } ``` ## Products Store products. ### List products `GET /v2/projects/{project_id}/products` · Auth: secret key or dashboard session · Permissions: `project_configuration:products:read` **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | **Query parameters** | Name | Type | Required | Description | |---|---|---|---| | `app_id` | string | no | Only this app's products. | | `expand` | array of `items.app`, `items.indicative_price` | no | `items.app` embeds each product's app. `items.indicative_price` adds each product's Test Store price. | | `limit` | integer | no | Page size. Values outside 1-100 are clamped, not rejected. | | `starting_after` | string | no | Id of the last item of the previous page. Use `next_page` instead of building it. | **Example request** ```bash curl -s "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/products" -H "Authorization: Bearer $SECRET_KEY" ``` **Responses** - **200**: A page of results. Returns a list of [Product](https://revenuedot.app/docs/api/rest-v2.md#product). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). ### Create a product `POST /v2/projects/{project_id}/products` · Auth: secret key or dashboard session · Permissions: `project_configuration:products:read_write` `store_identifier` is the store's product id. For Google Play subscriptions use `subscriptionId:basePlanId`. Set `subscription.duration` (ISO 8601, for example P1M): the Test Store uses it as the period, and MRR uses it for every store. `test_store_price` sets what the SDK shows for a Test Store product. **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | **Query parameters** | Name | Type | Required | Description | |---|---|---|---| | `expand` | array of `indicative_price` | no | `indicative_price` adds the Test Store price in RevenueCat's IndicativePrice shape (null for other stores and for products without a price). | **Request body** (`application/json`) | Field | Type | Required | Description | |---|---|---|---| | `store_identifier` | string | yes | | | `app_id` | string | yes | | | `type` | `subscription`, `one_time`, `consumable`, `non_consumable`, `non_renewing_subscription` | yes | | | `display_name` | string or null | no | | | `title` | string or null | no | Alias of display_name. | | `price_identifier` | string or null | no | Accepted and ignored. | | `subscription` | object or null | no | | | `subscription.duration` | string or null | no | ISO 8601 period such as P1W, P1M, P1Y or P3D. | | `test_store_price` | object or null | no | RevenueDot extension. The Test Store price the SDK shows for this product (Test Store products only). Null clears it. Read it back with `expand=indicative_price`. | | `test_store_price.amount_micros` | integer | yes | Price in micros: 9.99 is 9990000. | | `test_store_price.currency` | string | yes | ISO 4217 code such as USD or EUR. | **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/products" -H "Authorization: Bearer $SECRET_KEY" \ -H "Content-Type: application/json" -d '{"store_identifier":"pro_monthly","app_id":"appvnrm0a5h","type":"subscription","display_name":"Pro monthly","subscription":{"duration":"P1M"}}' ``` **Responses** - **201**: The product. Returns [Product](https://revenuedot.app/docs/api/rest-v2.md#product). - **400**: The request is invalid. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **409**: It already exists, or it conflicts with another object. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). Example 201 response: ```json { "object": "product", "id": "prode0zhpfisko", "store_identifier": "pro_monthly", "type": "subscription", "state": "active", "subscription": { "duration": "P1M", "grace_period_duration": null, "trial_duration": null }, "created_at": 1790800900948, "app_id": "appvnrm0a5h", "display_name": "Pro monthly" } ``` ### Get a product `GET /v2/projects/{project_id}/products/{product_id}` · Auth: secret key or dashboard session · Permissions: `project_configuration:products:read` **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | | `product_id` | string | yes | Product id (prod...). | **Query parameters** | Name | Type | Required | Description | |---|---|---|---| | `expand` | array of `app`, `indicative_price` | no | `app` embeds the app. `indicative_price` adds the Test Store price in RevenueCat's IndicativePrice shape (null for other stores and for products without a price). | **Example request** ```bash curl -s "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/products/$PRODUCT_ID" -H "Authorization: Bearer $SECRET_KEY" ``` **Responses** - **200**: The product. Returns [Product](https://revenuedot.app/docs/api/rest-v2.md#product). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). Example 200 response: ```json { "object": "product", "id": "prode0zhpfisko", "store_identifier": "pro_monthly", "type": "subscription", "state": "active", "subscription": { "duration": "P1M", "grace_period_duration": null, "trial_duration": null }, "created_at": 1790800900948, "app_id": "appvnrm0a5h", "display_name": "Pro monthly" } ``` ### Update a product `POST /v2/projects/{project_id}/products/{product_id}` · Auth: secret key or dashboard session · Permissions: `project_configuration:products:read_write` RevenueDot also lets you correct `type` and `subscription.duration` (null clears it), and set or clear `test_store_price`. **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | | `product_id` | string | yes | Product id. | **Query parameters** | Name | Type | Required | Description | |---|---|---|---| | `expand` | array of `app`, `indicative_price` | no | `indicative_price` adds the Test Store price in RevenueCat's IndicativePrice shape (null for other stores and for products without a price). | **Request body** (`application/json`) | Field | Type | Required | Description | |---|---|---|---| | `display_name` | string | no | | | `type` | `subscription`, `one_time`, `consumable`, `non_consumable`, `non_renewing_subscription` | no | | | `subscription` | object | no | | | `subscription.duration` | string or null | no | | | `test_store_price` | object or null | no | RevenueDot extension. The Test Store price the SDK shows for this product (Test Store products only). Null clears it. Read it back with `expand=indicative_price`. | | `test_store_price.amount_micros` | integer | yes | Price in micros: 9.99 is 9990000. | | `test_store_price.currency` | string | yes | ISO 4217 code such as USD or EUR. | **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/products/$PRODUCT_ID" -H "Authorization: Bearer $SECRET_KEY" \ -H "Content-Type: application/json" -d '{"display_name":"Pro (monthly)","test_store_price":{"amount_micros":9990000,"currency":"USD"}}' ``` **Responses** - **200**: The product. Returns [Product](https://revenuedot.app/docs/api/rest-v2.md#product). - **400**: The request is invalid. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). ### Delete a product `DELETE /v2/projects/{project_id}/products/{product_id}` · Auth: secret key or dashboard session · Permissions: `project_configuration:products:read_write` Detaches it from entitlements and packages. Purchase history keeps the store id. **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | | `product_id` | string | yes | Product id. | **Example request** ```bash curl -s -X DELETE "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/products/$PRODUCT_ID" -H "Authorization: Bearer $SECRET_KEY" ``` **Responses** - **200**: Deleted. Returns [Deleted](https://revenuedot.app/docs/api/rest-v2.md#deleted). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). Example 200 response: ```json { "object": "product", "id": "…", "deleted_at": 1790801342625 } ``` ### Archive a product `POST /v2/projects/{project_id}/products/{product_id}/actions/archive` · Auth: secret key or dashboard session · Permissions: `project_configuration:products:read_write` **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | | `product_id` | string | yes | Product id. | **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/products/$PRODUCT_ID/actions/archive" -H "Authorization: Bearer $SECRET_KEY" ``` **Responses** - **200**: The archived product. Returns [Product](https://revenuedot.app/docs/api/rest-v2.md#product). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). ### Unarchive a product `POST /v2/projects/{project_id}/products/{product_id}/actions/unarchive` · Auth: secret key or dashboard session · Permissions: `project_configuration:products:read_write` **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | | `product_id` | string | yes | Product id. | **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/products/$PRODUCT_ID/actions/unarchive" -H "Authorization: Bearer $SECRET_KEY" ``` **Responses** - **200**: The product. Returns [Product](https://revenuedot.app/docs/api/rest-v2.md#product). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). ## Entitlements The access your app checks, unlocked by products. ### List entitlements `GET /v2/projects/{project_id}/entitlements` · Auth: secret key or dashboard session · Permissions: `project_configuration:entitlements:read` **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | **Query parameters** | Name | Type | Required | Description | |---|---|---|---| | `expand` | array of `items.product` | no | `items.product` embeds the attached products. | | `limit` | integer | no | Page size. Values outside 1-100 are clamped, not rejected. | | `starting_after` | string | no | Id of the last item of the previous page. Use `next_page` instead of building it. | **Example request** ```bash curl -s "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/entitlements" -H "Authorization: Bearer $SECRET_KEY" ``` **Responses** - **200**: A page of results. Returns a list of [Entitlement](https://revenuedot.app/docs/api/rest-v2.md#entitlement). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). ### Create an entitlement `POST /v2/projects/{project_id}/entitlements` · Auth: secret key or dashboard session · Permissions: `project_configuration:entitlements:read_write` **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | **Request body** (`application/json`) | Field | Type | Required | Description | |---|---|---|---| | `lookup_key` | string | yes | What apps check, for example pro. | | `display_name` | string | yes | | **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/entitlements" -H "Authorization: Bearer $SECRET_KEY" \ -H "Content-Type: application/json" -d '{"lookup_key":"pro","display_name":"Pro access"}' ``` **Responses** - **201**: The entitlement. Returns [Entitlement](https://revenuedot.app/docs/api/rest-v2.md#entitlement). - **400**: The request is invalid. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **409**: It already exists, or it conflicts with another object. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). Example 201 response: ```json { "object": "entitlement", "id": "entl1v0bp6r0qs", "project_id": "proj18pzzkao", "lookup_key": "pro", "display_name": "Pro access", "created_at": 1790800901115, "state": "active" } ``` ### Get an entitlement `GET /v2/projects/{project_id}/entitlements/{entitlement_id}` · Auth: secret key or dashboard session · Permissions: `project_configuration:entitlements:read` **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | | `entitlement_id` | string | yes | Entitlement id (entl...). | **Query parameters** | Name | Type | Required | Description | |---|---|---|---| | `expand` | array of `product` | no | `product` embeds the attached products. | **Example request** ```bash curl -s "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/entitlements/$ENTITLEMENT_ID" -H "Authorization: Bearer $SECRET_KEY" ``` **Responses** - **200**: The entitlement. Returns [Entitlement](https://revenuedot.app/docs/api/rest-v2.md#entitlement). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). Example 200 response: ```json { "object": "entitlement", "id": "entl1v0bp6r0qs", "project_id": "proj18pzzkao", "lookup_key": "pro", "display_name": "Pro access", "created_at": 1790800901115, "state": "active" } ``` ### Rename an entitlement `POST /v2/projects/{project_id}/entitlements/{entitlement_id}` · Auth: secret key or dashboard session · Permissions: `project_configuration:entitlements:read_write` **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | | `entitlement_id` | string | yes | Entitlement id. | **Request body** (`application/json`) | Field | Type | Required | Description | |---|---|---|---| | `display_name` | string | yes | | **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/entitlements/$ENTITLEMENT_ID" -H "Authorization: Bearer $SECRET_KEY" ``` **Responses** - **200**: The entitlement. Returns [Entitlement](https://revenuedot.app/docs/api/rest-v2.md#entitlement). - **400**: The request is invalid. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). ### Delete an entitlement `DELETE /v2/projects/{project_id}/entitlements/{entitlement_id}` · Auth: secret key or dashboard session · Permissions: `project_configuration:entitlements:read_write` **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | | `entitlement_id` | string | yes | Entitlement id. | **Example request** ```bash curl -s -X DELETE "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/entitlements/$ENTITLEMENT_ID" -H "Authorization: Bearer $SECRET_KEY" ``` **Responses** - **200**: Deleted. Returns [Deleted](https://revenuedot.app/docs/api/rest-v2.md#deleted). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). Example 200 response: ```json { "object": "entitlement", "id": "…", "deleted_at": 1790801342625 } ``` ### Archive an entitlement `POST /v2/projects/{project_id}/entitlements/{entitlement_id}/actions/archive` · Auth: secret key or dashboard session · Permissions: `project_configuration:entitlements:read_write` **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | | `entitlement_id` | string | yes | Entitlement id. | **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/entitlements/$ENTITLEMENT_ID/actions/archive" -H "Authorization: Bearer $SECRET_KEY" ``` **Responses** - **200**: The archived entitlement. Returns [Entitlement](https://revenuedot.app/docs/api/rest-v2.md#entitlement). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). ### Unarchive an entitlement `POST /v2/projects/{project_id}/entitlements/{entitlement_id}/actions/unarchive` · Auth: secret key or dashboard session · Permissions: `project_configuration:entitlements:read_write` **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | | `entitlement_id` | string | yes | Entitlement id. | **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/entitlements/$ENTITLEMENT_ID/actions/unarchive" -H "Authorization: Bearer $SECRET_KEY" ``` **Responses** - **200**: The entitlement. Returns [Entitlement](https://revenuedot.app/docs/api/rest-v2.md#entitlement). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). ### List an entitlement's products `GET /v2/projects/{project_id}/entitlements/{entitlement_id}/products` · Auth: secret key or dashboard session · Permissions: `project_configuration:entitlements:read` **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | | `entitlement_id` | string | yes | Entitlement id. | **Query parameters** | Name | Type | Required | Description | |---|---|---|---| | `limit` | integer | no | Page size. Values outside 1-100 are clamped, not rejected. | | `starting_after` | string | no | Id of the last item of the previous page. Use `next_page` instead of building it. | **Example request** ```bash curl -s "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/entitlements/$ENTITLEMENT_ID/products" -H "Authorization: Bearer $SECRET_KEY" ``` **Responses** - **200**: A page of results. Returns a list of [Product](https://revenuedot.app/docs/api/rest-v2.md#product). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). ### Attach products to an entitlement `POST /v2/projects/{project_id}/entitlements/{entitlement_id}/actions/attach_products` · Auth: secret key or dashboard session · Permissions: `project_configuration:entitlements:read_write` Any of these products unlocks the entitlement. Every id must belong to the project, or nothing changes. **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | | `entitlement_id` | string | yes | Entitlement id. | **Request body** (`application/json`) | Field | Type | Required | Description | |---|---|---|---| | `product_ids` | array of string | yes | | **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/entitlements/$ENTITLEMENT_ID/actions/attach_products" -H "Authorization: Bearer $SECRET_KEY" \ -H "Content-Type: application/json" -d '{"product_ids":["prode0zhpfisko","prodz2c0dt6z9x"]}' ``` **Responses** - **200**: The entitlement with its products. Returns [Entitlement](https://revenuedot.app/docs/api/rest-v2.md#entitlement). - **400**: The request is invalid. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). ### Detach products from an entitlement `POST /v2/projects/{project_id}/entitlements/{entitlement_id}/actions/detach_products` · Auth: secret key or dashboard session · Permissions: `project_configuration:entitlements:read_write` **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | | `entitlement_id` | string | yes | Entitlement id. | **Request body** (`application/json`) | Field | Type | Required | Description | |---|---|---|---| | `product_ids` | array of string | yes | | **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/entitlements/$ENTITLEMENT_ID/actions/detach_products" -H "Authorization: Bearer $SECRET_KEY" ``` **Responses** - **200**: The entitlement with its products. Returns [Entitlement](https://revenuedot.app/docs/api/rest-v2.md#entitlement). - **400**: The request is invalid. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). ## Offerings Groups of packages the paywall shows. ### List offerings `GET /v2/projects/{project_id}/offerings` · Auth: secret key or dashboard session · Permissions: `project_configuration:offerings:read` **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | **Query parameters** | Name | Type | Required | Description | |---|---|---|---| | `expand` | array of `items.package`, `items.package.product` | no | Embed packages, and their products. | | `limit` | integer | no | Page size. Values outside 1-100 are clamped, not rejected. | | `starting_after` | string | no | Id of the last item of the previous page. Use `next_page` instead of building it. | **Example request** ```bash curl -s "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/offerings" -H "Authorization: Bearer $SECRET_KEY" ``` **Responses** - **200**: A page of results. Returns a list of [Offering](https://revenuedot.app/docs/api/rest-v2.md#offering). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). ### Create an offering `POST /v2/projects/{project_id}/offerings` · Auth: secret key or dashboard session · Permissions: `project_configuration:offerings:read_write` The project's first offering becomes current. **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | **Request body** (`application/json`) | Field | Type | Required | Description | |---|---|---|---| | `lookup_key` | string | yes | | | `display_name` | string | yes | | | `metadata` | object or null | no | | **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/offerings" -H "Authorization: Bearer $SECRET_KEY" \ -H "Content-Type: application/json" -d '{"lookup_key":"default","display_name":"Standard plans"}' ``` **Responses** - **201**: The offering. Returns [Offering](https://revenuedot.app/docs/api/rest-v2.md#offering). - **400**: The request is invalid. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **409**: It already exists, or it conflicts with another object. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). ### Get an offering `GET /v2/projects/{project_id}/offerings/{offering_id}` · Auth: secret key or dashboard session · Permissions: `project_configuration:offerings:read` **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | | `offering_id` | string | yes | Offering id (ofrng...). | **Query parameters** | Name | Type | Required | Description | |---|---|---|---| | `expand` | array of `package`, `package.product` | no | Embed packages, and their products. | **Example request** ```bash curl -s "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/offerings/$OFFERING_ID" -H "Authorization: Bearer $SECRET_KEY" ``` **Responses** - **200**: The offering. Returns [Offering](https://revenuedot.app/docs/api/rest-v2.md#offering). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). ### Update an offering or make it current `POST /v2/projects/{project_id}/offerings/{offering_id}` · Auth: secret key or dashboard session · Permissions: `project_configuration:offerings:read_write` `is_current: true` makes it the only current offering. An archived offering cannot be made current (422). **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | | `offering_id` | string | yes | Offering id. | **Request body** (`application/json`) | Field | Type | Required | Description | |---|---|---|---| | `display_name` | string | no | | | `is_current` | boolean | no | | | `metadata` | object or null | no | | **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/offerings/$OFFERING_ID" -H "Authorization: Bearer $SECRET_KEY" \ -H "Content-Type: application/json" -d '{"is_current":true}' ``` **Responses** - **200**: The offering. Returns [Offering](https://revenuedot.app/docs/api/rest-v2.md#offering). - **400**: The request is invalid. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **422**: The request is valid but cannot be done in this state or for this store. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). ### Delete an offering `DELETE /v2/projects/{project_id}/offerings/{offering_id}` · Auth: secret key or dashboard session · Permissions: `project_configuration:offerings:read_write` Deletes its packages and clears customer overrides that point to it. **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | | `offering_id` | string | yes | Offering id. | **Example request** ```bash curl -s -X DELETE "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/offerings/$OFFERING_ID" -H "Authorization: Bearer $SECRET_KEY" ``` **Responses** - **200**: Deleted. Returns [Deleted](https://revenuedot.app/docs/api/rest-v2.md#deleted). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). Example 200 response: ```json { "object": "offering", "id": "…", "deleted_at": 1790801342625 } ``` ### Archive an offering `POST /v2/projects/{project_id}/offerings/{offering_id}/actions/archive` · Auth: secret key or dashboard session · Permissions: `project_configuration:offerings:read_write` **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | | `offering_id` | string | yes | Offering id. | **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/offerings/$OFFERING_ID/actions/archive" -H "Authorization: Bearer $SECRET_KEY" ``` **Responses** - **200**: The archived offering. Returns [Offering](https://revenuedot.app/docs/api/rest-v2.md#offering). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **422**: The request is valid but cannot be done in this state or for this store. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). ### Unarchive an offering `POST /v2/projects/{project_id}/offerings/{offering_id}/actions/unarchive` · Auth: secret key or dashboard session · Permissions: `project_configuration:offerings:read_write` **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | | `offering_id` | string | yes | Offering id. | **Request body** (`application/json`) | Field | Type | Required | Description | |---|---|---|---| | `unarchive_referenced_entities` | boolean | no | Also unarchive the products in its packages. | **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/offerings/$OFFERING_ID/actions/unarchive" -H "Authorization: Bearer $SECRET_KEY" ``` **Responses** - **200**: The offering. Returns [Offering](https://revenuedot.app/docs/api/rest-v2.md#offering). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). ## Packages One choice on the paywall, with one product per app. ### List an offering's packages `GET /v2/projects/{project_id}/offerings/{offering_id}/packages` · Auth: secret key or dashboard session · Permissions: `project_configuration:packages:read` Ordered by position, then creation: the order the SDK shows them in. **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | | `offering_id` | string | yes | Offering id. | **Query parameters** | Name | Type | Required | Description | |---|---|---|---| | `expand` | array of `items.product` | no | Embed products. | | `limit` | integer | no | Page size. Values outside 1-100 are clamped, not rejected. | | `starting_after` | string | no | Id of the last item of the previous page. Use `next_page` instead of building it. | **Example request** ```bash curl -s "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/offerings/$OFFERING_ID/packages" -H "Authorization: Bearer $SECRET_KEY" ``` **Responses** - **200**: A page of results. Returns a list of [Package](https://revenuedot.app/docs/api/rest-v2.md#package). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). ### Create a package `POST /v2/projects/{project_id}/offerings/{offering_id}/packages` · Auth: secret key or dashboard session · Permissions: `project_configuration:packages:read_write` Use the standard lookup keys (`$rc_monthly`, `$rc_annual`, `$rc_weekly`, `$rc_lifetime` ...) so the SDK's convenience accessors work. Without `position`, the package goes last. **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | | `offering_id` | string | yes | Offering id. | **Request body** (`application/json`) | Field | Type | Required | Description | |---|---|---|---| | `lookup_key` | string | yes | | | `display_name` | string | yes | | | `position` | integer | no | | **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/offerings/$OFFERING_ID/packages" -H "Authorization: Bearer $SECRET_KEY" \ -H "Content-Type: application/json" -d '{"lookup_key":"$rc_monthly","display_name":"Monthly","position":0}' ``` **Responses** - **201**: The package. Returns [Package](https://revenuedot.app/docs/api/rest-v2.md#package). - **400**: The request is invalid. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **409**: It already exists, or it conflicts with another object. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). ### Get a package `GET /v2/projects/{project_id}/packages/{package_id}` · Auth: secret key or dashboard session · Permissions: `project_configuration:packages:read` **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | | `package_id` | string | yes | Package id (pkge...). | **Query parameters** | Name | Type | Required | Description | |---|---|---|---| | `expand` | array of `product` | no | Embed products. | **Example request** ```bash curl -s "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/packages/$PACKAGE_ID" -H "Authorization: Bearer $SECRET_KEY" ``` **Responses** - **200**: The package. Returns [Package](https://revenuedot.app/docs/api/rest-v2.md#package). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). ### Update a package `POST /v2/projects/{project_id}/packages/{package_id}` · Auth: secret key or dashboard session · Permissions: `project_configuration:packages:read_write` **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | | `package_id` | string | yes | Package id. | **Request body** (`application/json`) | Field | Type | Required | Description | |---|---|---|---| | `display_name` | string | no | | | `position` | integer | no | | **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/packages/$PACKAGE_ID" -H "Authorization: Bearer $SECRET_KEY" ``` **Responses** - **200**: The package. Returns [Package](https://revenuedot.app/docs/api/rest-v2.md#package). - **400**: The request is invalid. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). ### Delete a package `DELETE /v2/projects/{project_id}/packages/{package_id}` · Auth: secret key or dashboard session · Permissions: `project_configuration:packages:read_write` **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | | `package_id` | string | yes | Package id. | **Example request** ```bash curl -s -X DELETE "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/packages/$PACKAGE_ID" -H "Authorization: Bearer $SECRET_KEY" ``` **Responses** - **200**: Deleted. Returns [Deleted](https://revenuedot.app/docs/api/rest-v2.md#deleted). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). Example 200 response: ```json { "object": "package", "id": "…", "deleted_at": 1790801342625 } ``` ### List a package's products `GET /v2/projects/{project_id}/packages/{package_id}/products` · Auth: secret key or dashboard session · Permissions: `project_configuration:packages:read` **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | | `package_id` | string | yes | Package id. | **Query parameters** | Name | Type | Required | Description | |---|---|---|---| | `limit` | integer | no | Page size. Values outside 1-100 are clamped, not rejected. | | `starting_after` | string | no | Id of the last item of the previous page. Use `next_page` instead of building it. | **Example request** ```bash curl -s "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/packages/$PACKAGE_ID/products" -H "Authorization: Bearer $SECRET_KEY" ``` **Responses** - **200**: A page of results. Returns a list of [PackageProduct](https://revenuedot.app/docs/api/rest-v2.md#packageproduct). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). ### Attach products to a package `POST /v2/projects/{project_id}/packages/{package_id}/actions/attach_products` · Auth: secret key or dashboard session · Permissions: `project_configuration:packages:read_write` One product per app, so each app's SDK finds its product. Two products of the same app can share a package only with non-overlapping `eligibility_criteria` (409 otherwise). **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | | `package_id` | string | yes | Package id. | **Request body** (`application/json`) | Field | Type | Required | Description | |---|---|---|---| | `products` | array of object | yes | | | `products[].product_id` | string | yes | | | `products[].eligibility_criteria` | `all`, `google_sdk_lt_6`, `google_sdk_ge_6` | yes | | **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/packages/$PACKAGE_ID/actions/attach_products" -H "Authorization: Bearer $SECRET_KEY" \ -H "Content-Type: application/json" -d '{"products":[{"product_id":"prode0zhpfisko","eligibility_criteria":"all"}]}' ``` **Responses** - **200**: The package with its products. Returns [Package](https://revenuedot.app/docs/api/rest-v2.md#package). - **400**: The request is invalid. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **409**: It already exists, or it conflicts with another object. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). ### Detach products from a package `POST /v2/projects/{project_id}/packages/{package_id}/actions/detach_products` · Auth: secret key or dashboard session · Permissions: `project_configuration:packages:read_write` **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | | `package_id` | string | yes | Package id. | **Request body** (`application/json`) | Field | Type | Required | Description | |---|---|---|---| | `product_ids` | array of string | yes | | **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/packages/$PACKAGE_ID/actions/detach_products" -H "Authorization: Bearer $SECRET_KEY" ``` **Responses** - **200**: The package with its products. Returns [Package](https://revenuedot.app/docs/api/rest-v2.md#package). - **400**: The request is invalid. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). ## Customers Customers, their attributes, entitlements, subscriptions, purchases and events. ### List or search customers `GET /v2/projects/{project_id}/customers` · Auth: secret key or dashboard session · Permissions: `customer_information:customers:read` Newest first (by first seen). **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | **Query parameters** | Name | Type | Required | Description | |---|---|---|---| | `search` | string | no | Exact match on an app user id, the `$email` attribute (any case) or a store transaction id. | | `limit` | integer | no | Page size. Values outside 1-100 are clamped, not rejected. | | `starting_after` | string | no | Id of the last item of the previous page. Use `next_page` instead of building it. | **Example request** ```bash curl -s "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/customers" -H "Authorization: Bearer $SECRET_KEY" ``` **Responses** - **200**: A page of results. Returns a list of [Customer](https://revenuedot.app/docs/api/rest-v2.md#customer). - **400**: The request is invalid. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). ### Create a customer `POST /v2/projects/{project_id}/customers` · Auth: secret key or dashboard session · Permissions: `customer_information:customers:read_write` **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | **Request body** (`application/json`) | Field | Type | Required | Description | |---|---|---|---| | `id` | string | yes | App user id. | | `attributes` | array of object | no | | | `attributes[].name` | string | yes | | | `attributes[].value` | string | yes | | **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/customers" -H "Authorization: Bearer $SECRET_KEY" \ -H "Content-Type: application/json" -d '{"id":"user_42","attributes":[{"name":"$email","value":"ana@example.com"}]}' ``` **Responses** - **201**: The customer. Returns [Customer](https://revenuedot.app/docs/api/rest-v2.md#customer). - **400**: The request is invalid. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **409**: It already exists, or it conflicts with another object. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). ### Get a customer `GET /v2/projects/{project_id}/customers/{customer_id}` · Auth: secret key or dashboard session · Permissions: `customer_information:customers:read` **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | | `customer_id` | string | yes | Any app user id of the customer. | **Query parameters** | Name | Type | Required | Description | |---|---|---|---| | `expand` | array of `attributes` | no | `attributes` embeds the customer's attributes. | **Example request** ```bash curl -s "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/customers/user_1" -H "Authorization: Bearer $SECRET_KEY" ``` **Responses** - **200**: The customer. Returns [Customer](https://revenuedot.app/docs/api/rest-v2.md#customer). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). Example 200 response: ```json { "object": "customer", "id": "user_1", "project_id": "proj18pzzkao", "first_seen_at": 1790800914012, "last_seen_at": 1790800914034, "last_seen_app_version": null, "last_seen_country": null, "last_seen_platform": null, "last_seen_platform_version": null, "active_entitlements": { "object": "list", "items": [ { "object": "customer.active_entitlement", "entitlement_id": "entl1v0bp6r0qs", "expires_at": 1793392914000 } ], "next_page": null, "url": "/v2/projects/proj18pzzkao/customers/user_1/active_entitlements" }, "experiment": null } ``` ### Delete a customer `DELETE /v2/projects/{project_id}/customers/{customer_id}` · Auth: secret key or dashboard session · Permissions: `customer_information:customers:read_write` Deletes aliases, attributes, subscriptions, purchases, transactions and events. Cannot be undone. **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | | `customer_id` | string | yes | Any app user id of the customer. | **Example request** ```bash curl -s -X DELETE "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/customers/user_1" -H "Authorization: Bearer $SECRET_KEY" ``` **Responses** - **200**: Deleted. Returns [Deleted](https://revenuedot.app/docs/api/rest-v2.md#deleted). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). Example 200 response: ```json { "object": "customer", "id": "…", "deleted_at": 1790801342625 } ``` ### List a customer's app user ids `GET /v2/projects/{project_id}/customers/{customer_id}/aliases` · Auth: secret key or dashboard session · Permissions: `customer_information:customers:read` **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | | `customer_id` | string | yes | Any app user id of the customer. | **Query parameters** | Name | Type | Required | Description | |---|---|---|---| | `limit` | integer | no | Page size. Values outside 1-100 are clamped, not rejected. | | `starting_after` | string | no | Id of the last item of the previous page. Use `next_page` instead of building it. | **Example request** ```bash curl -s "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/customers/user_1/aliases" -H "Authorization: Bearer $SECRET_KEY" ``` **Responses** - **200**: A page of results. Returns a list of [CustomerAlias](https://revenuedot.app/docs/api/rest-v2.md#customeralias). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). ### List a customer's attributes `GET /v2/projects/{project_id}/customers/{customer_id}/attributes` · Auth: secret key or dashboard session · Permissions: `customer_information:customers:read` **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | | `customer_id` | string | yes | Any app user id of the customer. | **Query parameters** | Name | Type | Required | Description | |---|---|---|---| | `limit` | integer | no | Page size. Values outside 1-100 are clamped, not rejected. | | `starting_after` | string | no | Id of the last item of the previous page. Use `next_page` instead of building it. | **Example request** ```bash curl -s "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/customers/user_1/attributes" -H "Authorization: Bearer $SECRET_KEY" ``` **Responses** - **200**: A page of results. Returns a list of [CustomerAttribute](https://revenuedot.app/docs/api/rest-v2.md#customerattribute). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). ### Set a customer's attributes `POST /v2/projects/{project_id}/customers/{customer_id}/attributes` · Auth: secret key or dashboard session · Permissions: `customer_information:customers:read_write` API writes always win over older SDK writes. A null value deletes the attribute. **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | | `customer_id` | string | yes | Any app user id of the customer. | **Request body** (`application/json`) | Field | Type | Required | Description | |---|---|---|---| | `attributes` | array of object | yes | | | `attributes[].name` | string | yes | | | `attributes[].value` | string or null | yes | | **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/customers/user_1/attributes" -H "Authorization: Bearer $SECRET_KEY" \ -H "Content-Type: application/json" -d '{"attributes":[{"name":"$displayName","value":"Ana"}]}' ``` **Responses** - **200**: Every attribute of the customer. Returns a list of [CustomerAttribute](https://revenuedot.app/docs/api/rest-v2.md#customerattribute). - **400**: The request is invalid. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). ### List a customer's active entitlements `GET /v2/projects/{project_id}/customers/{customer_id}/active_entitlements` · Auth: secret key or dashboard session · Permissions: `customer_information:customers:read` **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | | `customer_id` | string | yes | Any app user id of the customer. | **Query parameters** | Name | Type | Required | Description | |---|---|---|---| | `limit` | integer | no | Page size. Values outside 1-100 are clamped, not rejected. | | `starting_after` | string | no | Id of the last item of the previous page. Use `next_page` instead of building it. | **Example request** ```bash curl -s "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/customers/user_1/active_entitlements" -H "Authorization: Bearer $SECRET_KEY" ``` **Responses** - **200**: A page of results. Returns a list of [ActiveEntitlement](https://revenuedot.app/docs/api/rest-v2.md#activeentitlement). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). ### List a customer's subscriptions `GET /v2/projects/{project_id}/customers/{customer_id}/subscriptions` · Auth: secret key or dashboard session · Permissions: `customer_information:subscriptions:read` **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | | `customer_id` | string | yes | Any app user id of the customer. | **Query parameters** | Name | Type | Required | Description | |---|---|---|---| | `environment` | `production`, `sandbox` | no | Only this environment. Default: both. | | `limit` | integer | no | Page size. Values outside 1-100 are clamped, not rejected. | | `starting_after` | string | no | Id of the last item of the previous page. Use `next_page` instead of building it. | **Example request** ```bash curl -s "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/customers/user_1/subscriptions" -H "Authorization: Bearer $SECRET_KEY" ``` **Responses** - **200**: A page of subscriptions. Returns a list of [Subscription](https://revenuedot.app/docs/api/rest-v2.md#subscription). - **400**: The request is invalid. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). Example 200 response: ```json { "object": "list", "items": [ { "object": "subscription", "id": "sub_k1u15wepvw0dfh25", "customer_id": "user_1", "original_customer_id": "user_1", "product_id": "prode0zhpfisko", "starts_at": 1790800914000, "current_period_starts_at": 1790800914000, "current_period_ends_at": 1793392914000, "ends_at": 1793392914000, "gives_access": true, "pending_payment": false, "auto_renewal_status": "will_renew", "status": "active", "total_revenue_in_usd": { "currency": "USD", "gross": 9.99, "commission": 0, "tax": 0, "proceeds": 9.99 }, "presented_offering_id": null, "entitlements": { "object": "list", "items": [ { "object": "entitlement", "id": "entl1v0bp6r0qs", "project_id": "proj18pzzkao", "lookup_key": "pro", "display_name": "Pro access", "created_at": 1790800901115, "state": "active" } ], "next_page": null, "url": "/v2/projects/proj18pzzkao/subscriptions/sub_k1u15wepvw0dfh25/entitlements" }, "environment": "sandbox", "store": "test_store", "store_subscription_identifier": "test_1790800914000_quickstart", "ownership": "purchased", "management_url": null } ], "next_page": null, "url": "/v2/projects/proj18pzzkao/customers/user_1/subscriptions" } ``` ### List a customer's one-time purchases `GET /v2/projects/{project_id}/customers/{customer_id}/purchases` · Auth: secret key or dashboard session · Permissions: `customer_information:purchases:read` **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | | `customer_id` | string | yes | Any app user id of the customer. | **Query parameters** | Name | Type | Required | Description | |---|---|---|---| | `environment` | `production`, `sandbox` | no | Only this environment. Default: both. | | `limit` | integer | no | Page size. Values outside 1-100 are clamped, not rejected. | | `starting_after` | string | no | Id of the last item of the previous page. Use `next_page` instead of building it. | **Example request** ```bash curl -s "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/customers/user_1/purchases" -H "Authorization: Bearer $SECRET_KEY" ``` **Responses** - **200**: A page of results. Returns a list of [Purchase](https://revenuedot.app/docs/api/rest-v2.md#purchase). - **400**: The request is invalid. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). ### List a customer's events `GET /v2/projects/{project_id}/customers/{customer_id}/events` · Auth: secret key or dashboard session · Permissions: `customer_information:customers:read` Newest first. `body` is the webhook event. **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | | `customer_id` | string | yes | Any app user id of the customer. | **Query parameters** | Name | Type | Required | Description | |---|---|---|---| | `environment` | `production`, `sandbox` | no | Only this environment. Default: both. | | `limit` | integer | no | Page size. Values outside 1-100 are clamped, not rejected. | | `starting_after` | string | no | Id of the last item of the previous page. Use `next_page` instead of building it. | **Example request** ```bash curl -s "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/customers/user_1/events" -H "Authorization: Bearer $SECRET_KEY" ``` **Responses** - **200**: A page of results. Returns a list of [CustomerEvent](https://revenuedot.app/docs/api/rest-v2.md#customerevent). - **400**: The request is invalid. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). ### Grant an entitlement `POST /v2/projects/{project_id}/customers/{customer_id}/actions/grant_entitlement` · Auth: secret key or dashboard session · Permissions: `customer_information:customers:read_write` Promotional access until `expires_at`. A grant ending within 2 hours of an existing grant for the same entitlement changes nothing. **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | | `customer_id` | string | yes | Any app user id of the customer. | **Request body** (`application/json`) | Field | Type | Required | Description | |---|---|---|---| | `entitlement_id` | string | yes | Entitlement id (entl...). | | `expires_at` | integer | yes | Epoch milliseconds, in the future. | **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/customers/user_1/actions/grant_entitlement" -H "Authorization: Bearer $SECRET_KEY" \ -H "Content-Type: application/json" -d '{"entitlement_id":"entl1v0bp6r0qs","expires_at":1830000000000}' ``` **Responses** - **201**: The customer. Returns [Customer](https://revenuedot.app/docs/api/rest-v2.md#customer). - **400**: The request is invalid. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). ### Revoke a granted entitlement `POST /v2/projects/{project_id}/customers/{customer_id}/actions/revoke_granted_entitlement` · Auth: secret key or dashboard session · Permissions: `customer_information:customers:read_write` **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | | `customer_id` | string | yes | Any app user id of the customer. | **Request body** (`application/json`) | Field | Type | Required | Description | |---|---|---|---| | `entitlement_id` | string | yes | | **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/customers/user_1/actions/revoke_granted_entitlement" -H "Authorization: Bearer $SECRET_KEY" ``` **Responses** - **200**: The customer. Returns [Customer](https://revenuedot.app/docs/api/rest-v2.md#customer). - **400**: The request is invalid. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). ### Assign an offering to a customer `POST /v2/projects/{project_id}/customers/{customer_id}/actions/assign_offering` · Auth: secret key or dashboard session · Permissions: `project_configuration:offerings:read`, `customer_information:customers:read_write` The customer sees this offering as current. `null` removes the override. **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | | `customer_id` | string | yes | Any app user id of the customer. | **Request body** (`application/json`) | Field | Type | Required | Description | |---|---|---|---| | `offering_id` | string or null | yes | Offering id (ofrng...) or null. | **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/customers/user_1/actions/assign_offering" -H "Authorization: Bearer $SECRET_KEY" \ -H "Content-Type: application/json" -d '{"offering_id":"ofrngjfr71v5awb"}' ``` **Responses** - **200**: Done. - **400**: The request is invalid. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). Example 200 response: ```json {} ``` ## Subscriptions Subscriptions across customers, and store actions on them. ### Find subscriptions by store id `GET /v2/projects/{project_id}/subscriptions` · Auth: secret key or dashboard session · Permissions: `customer_information:subscriptions:read` **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | **Query parameters** | Name | Type | Required | Description | |---|---|---|---| | `store_subscription_identifier` | string | yes | Store transaction id, original transaction id or purchase token. | **Example request** ```bash curl -s "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/subscriptions" -H "Authorization: Bearer $SECRET_KEY" ``` **Responses** - **200**: A page of results. Returns a list of [Subscription](https://revenuedot.app/docs/api/rest-v2.md#subscription). - **400**: The request is invalid. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). ### Get a subscription `GET /v2/projects/{project_id}/subscriptions/{subscription_id}` · Auth: secret key or dashboard session · Permissions: `customer_information:subscriptions:read` **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | | `subscription_id` | string | yes | Subscription id (sub_...). | **Example request** ```bash curl -s "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/subscriptions/$SUBSCRIPTION_ID" -H "Authorization: Bearer $SECRET_KEY" ``` **Responses** - **200**: The subscription. Returns [Subscription](https://revenuedot.app/docs/api/rest-v2.md#subscription). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). Example 200 response: ```json { "object": "subscription", "id": "sub_k1u15wepvw0dfh25", "customer_id": "user_1", "original_customer_id": "user_1", "product_id": "prode0zhpfisko", "starts_at": 1790800914000, "current_period_starts_at": 1790800914000, "current_period_ends_at": 1793392914000, "ends_at": 1793392914000, "gives_access": true, "pending_payment": false, "auto_renewal_status": "will_renew", "status": "active", "total_revenue_in_usd": { "currency": "USD", "gross": 9.99, "commission": 0, "tax": 0, "proceeds": 9.99 }, "presented_offering_id": null, "entitlements": { "object": "list", "items": [ { "object": "entitlement", "id": "entl1v0bp6r0qs", "project_id": "proj18pzzkao", "lookup_key": "pro", "display_name": "Pro access", "created_at": 1790800901115, "state": "active" } ], "next_page": null, "url": "/v2/projects/proj18pzzkao/subscriptions/sub_k1u15wepvw0dfh25/entitlements" }, "environment": "sandbox", "store": "test_store", "store_subscription_identifier": "test_1790800914000_quickstart", "ownership": "purchased", "management_url": null } ``` ### List the entitlements a subscription unlocks `GET /v2/projects/{project_id}/subscriptions/{subscription_id}/entitlements` · Auth: secret key or dashboard session · Permissions: `customer_information:subscriptions:read` **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | | `subscription_id` | string | yes | Subscription id. | **Query parameters** | Name | Type | Required | Description | |---|---|---|---| | `limit` | integer | no | Page size. Values outside 1-100 are clamped, not rejected. | | `starting_after` | string | no | Id of the last item of the previous page. Use `next_page` instead of building it. | **Example request** ```bash curl -s "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/subscriptions/$SUBSCRIPTION_ID/entitlements" -H "Authorization: Bearer $SECRET_KEY" ``` **Responses** - **200**: A page of results. Returns a list of [Entitlement](https://revenuedot.app/docs/api/rest-v2.md#entitlement). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). ### List a subscription's payments `GET /v2/projects/{project_id}/subscriptions/{subscription_id}/transactions` · Auth: secret key or dashboard session · Permissions: `customer_information:subscriptions:read` One item per paid store transaction of the subscription (purchase, trial start, renewal). A refunded payment's `effective_expiration_date` is the refund time. **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | | `subscription_id` | string | yes | Subscription id. | **Query parameters** | Name | Type | Required | Description | |---|---|---|---| | `sort` | `id`, `purchased_at` | no | Default id. | | `direction` | `asc`, `desc` | no | Default asc. | | `limit` | integer | no | Page size. Values outside 1-100 are clamped, not rejected. | | `starting_after` | string | no | Id of the last item of the previous page. Use `next_page` instead of building it. | **Example request** ```bash curl -s "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/subscriptions/$SUBSCRIPTION_ID/transactions" -H "Authorization: Bearer $SECRET_KEY" ``` **Responses** - **200**: A page of results. Returns a list of [SubscriptionTransaction](https://revenuedot.app/docs/api/rest-v2.md#subscriptiontransaction). - **400**: The request is invalid. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). ### Cancel a subscription (Google Play) `POST /v2/projects/{project_id}/subscriptions/{subscription_id}/actions/cancel` · Auth: secret key or dashboard session · Permissions: `customer_information:subscriptions:read_write` Google Play only: turns auto-renew off. Other stores answer 422. **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | | `subscription_id` | string | yes | Subscription id. | **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/subscriptions/$SUBSCRIPTION_ID/actions/cancel" -H "Authorization: Bearer $SECRET_KEY" ``` **Responses** - **200**: The subscription. Returns [Subscription](https://revenuedot.app/docs/api/rest-v2.md#subscription). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **422**: The request is valid but cannot be done in this state or for this store. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **503**: The store could not be reached. Retry later. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). ### Refund and revoke a subscription (Google Play) `POST /v2/projects/{project_id}/subscriptions/{subscription_id}/actions/refund` · Auth: secret key or dashboard session · Permissions: `customer_information:subscriptions:read_write` Google Play only: refunds the latest payment and ends access now. App Store refunds go through Apple. Other stores answer 422. **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | | `subscription_id` | string | yes | Subscription id. | **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/subscriptions/$SUBSCRIPTION_ID/actions/refund" -H "Authorization: Bearer $SECRET_KEY" ``` **Responses** - **200**: The subscription. Returns [Subscription](https://revenuedot.app/docs/api/rest-v2.md#subscription). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **422**: The request is valid but cannot be done in this state or for this store. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **503**: The store could not be reached. Retry later. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). ### Extend a subscription `POST /v2/projects/{project_id}/subscriptions/{subscription_id}/actions/extend` · Auth: secret key or dashboard session · Permissions: `customer_information:subscriptions:read_write` App Store: Apple extends the renewal date (1 to 90 days, `extend_reason_code` required, needs the in-app purchase key). Google Play: the renewal is deferred (up to 365 days). Send `extend_by_days` or `extend_until_ms`, not both. **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | | `subscription_id` | string | yes | Subscription id. | **Request body** (`application/json`) | Field | Type | Required | Description | |---|---|---|---| | `extend_by_days` | integer | yes | | | `extend_reason_code` | `undeclared`, `customer_satisfaction`, `other`, `service_issue_or_outage` | no | Apple's reason for the extension. Required for App Store subscriptions. | | `extend_until_ms` | integer | yes | New end, epoch milliseconds. | **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/subscriptions/$SUBSCRIPTION_ID/actions/extend" -H "Authorization: Bearer $SECRET_KEY" \ -H "Content-Type: application/json" -d '{"extend_by_days":7,"extend_reason_code":"customer_satisfaction"}' ``` **Responses** - **200**: The subscription. Returns [Subscription](https://revenuedot.app/docs/api/rest-v2.md#subscription). - **400**: The request is invalid. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **422**: The request is valid but cannot be done in this state or for this store. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **503**: The store could not be reached. Retry later. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). ### Refund one payment of a subscription (Google Play) `POST /v2/projects/{project_id}/subscriptions/{subscription_id}/transactions/{transaction_id}/actions/refund` · Auth: secret key or dashboard session · Permissions: `customer_information:subscriptions:read_write` **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | | `subscription_id` | string | yes | Subscription id. | | `transaction_id` | string | yes | Google order id of the payment. | **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/subscriptions/$SUBSCRIPTION_ID/transactions/$TRANSACTION_ID/actions/refund" -H "Authorization: Bearer $SECRET_KEY" ``` **Responses** - **200**: The refunded payment. Returns [SubscriptionTransaction](https://revenuedot.app/docs/api/rest-v2.md#subscriptiontransaction). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **422**: The request is valid but cannot be done in this state or for this store. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **503**: The store could not be reached. Retry later. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). ## Purchases One-time purchases across customers. ### Find one-time purchases by store id `GET /v2/projects/{project_id}/purchases` · Auth: secret key or dashboard session · Permissions: `customer_information:purchases:read` **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | **Query parameters** | Name | Type | Required | Description | |---|---|---|---| | `store_purchase_identifier` | string | yes | Store transaction id. | **Example request** ```bash curl -s "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/purchases" -H "Authorization: Bearer $SECRET_KEY" ``` **Responses** - **200**: A page of results. Returns a list of [Purchase](https://revenuedot.app/docs/api/rest-v2.md#purchase). - **400**: The request is invalid. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). ### Get a one-time purchase `GET /v2/projects/{project_id}/purchases/{purchase_id}` · Auth: secret key or dashboard session · Permissions: `customer_information:purchases:read` **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | | `purchase_id` | string | yes | Purchase id. | **Example request** ```bash curl -s "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/purchases/$PURCHASE_ID" -H "Authorization: Bearer $SECRET_KEY" ``` **Responses** - **200**: The purchase. Returns [Purchase](https://revenuedot.app/docs/api/rest-v2.md#purchase). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). ### List the entitlements a purchase unlocks `GET /v2/projects/{project_id}/purchases/{purchase_id}/entitlements` · Auth: secret key or dashboard session · Permissions: `customer_information:purchases:read` **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | | `purchase_id` | string | yes | Purchase id. | **Query parameters** | Name | Type | Required | Description | |---|---|---|---| | `limit` | integer | no | Page size. Values outside 1-100 are clamped, not rejected. | | `starting_after` | string | no | Id of the last item of the previous page. Use `next_page` instead of building it. | **Example request** ```bash curl -s "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/purchases/$PURCHASE_ID/entitlements" -H "Authorization: Bearer $SECRET_KEY" ``` **Responses** - **200**: A page of results. Returns a list of [Entitlement](https://revenuedot.app/docs/api/rest-v2.md#entitlement). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). ### Refund a one-time purchase (Google Play) `POST /v2/projects/{project_id}/purchases/{purchase_id}/actions/refund` · Auth: secret key or dashboard session · Permissions: `customer_information:purchases:read_write` Google Play refunds and revokes the order. Other stores answer 422. **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | | `purchase_id` | string | yes | Purchase id. | **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/purchases/$PURCHASE_ID/actions/refund" -H "Authorization: Bearer $SECRET_KEY" ``` **Responses** - **200**: The purchase. Returns [Purchase](https://revenuedot.app/docs/api/rest-v2.md#purchase). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **422**: The request is valid but cannot be done in this state or for this store. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **503**: The store could not be reached. Retry later. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). ## Metrics The dashboard overview numbers. ### Overview metrics `GET /v2/projects/{project_id}/metrics/overview` · Auth: secret key or dashboard session · Permissions: `charts_metrics:overview:read` Computed live: active trials, active paid subscriptions, MRR (USD price normalised to a month), revenue in the last 28 days, new and active customers in the last 28 days. **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | **Query parameters** | Name | Type | Required | Description | |---|---|---|---| | `currency` | `"USD"` | no | Only USD is supported. | | `environment` | `production`, `sandbox` | no | RevenueDot extension. Default production. | **Example request** ```bash curl -s "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/metrics/overview" -H "Authorization: Bearer $SECRET_KEY" ``` **Responses** - **200**: The metrics. Returns [OverviewMetrics](https://revenuedot.app/docs/api/rest-v2.md#overviewmetrics). - **400**: The request is invalid. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). ## Webhook integrations Where events are sent. ### List webhooks `GET /v2/projects/{project_id}/integrations/webhooks` · Auth: secret key or dashboard session · Permissions: `project_configuration:integrations:read` **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | **Query parameters** | Name | Type | Required | Description | |---|---|---|---| | `limit` | integer | no | Page size. Values outside 1-100 are clamped, not rejected. | | `starting_after` | string | no | Id of the last item of the previous page. Use `next_page` instead of building it. | **Example request** ```bash curl -s "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/integrations/webhooks" -H "Authorization: Bearer $SECRET_KEY" ``` **Responses** - **200**: A page of results. Returns a list of [WebhookIntegration](https://revenuedot.app/docs/api/rest-v2.md#webhookintegration). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). ### Create a webhook `POST /v2/projects/{project_id}/integrations/webhooks` · Auth: secret key or dashboard session · Permissions: `project_configuration:integrations:read_write` The answer includes `signing_secret` (whsec_...) once. Store it: it verifies the `X-RevenueCat-Webhook-Signature` header. See [Webhooks](https://revenuedot.app/docs/guides/webhooks.md). **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | **Request body** (`application/json`) | Field | Type | Required | Description | |---|---|---|---| | `name` | string | yes | | | `url` | string | yes | http(s) URL. | | `authorization_header` | string or null | no | Sent as the Authorization header. | | `environment` | `production`, `sandbox`, null | no | Null or absent: both. | | `event_types` | array of `initial_purchase`, `renewal`, `product_change`, `cancellation`, `billing_issue`, `non_renewing_purchase`, `uncancellation`, `transfer`, `subscription_paused`, `expiration`, `subscription_extended`, `invoice_issuance`, `temporary_entitlement_grant`, `refund_reversed`, `virtual_currency_transaction`, `test`, `experiment_enrollment`, `purchase_redeemed`, `subscriber_alias`, `price_increase_consent_required`, `price_increase_consent_approved` | no | Empty or absent: every type. | | `app_id` | string or null | no | Only this app's events. | **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/integrations/webhooks" -H "Authorization: Bearer $SECRET_KEY" \ -H "Content-Type: application/json" -d '{"name":"Backend","url":"https://api.example.com/webhooks/revenuedot","authorization_header":"Bearer my-shared-token","environment":"production","event_types":["initial_purchase","renewal"]}' ``` **Responses** - **201**: The webhook with its signing secret. Returns [WebhookIntegration](https://revenuedot.app/docs/api/rest-v2.md#webhookintegration). - **400**: The request is invalid. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). Example 201 response: ```json { "object": "webhook_integration", "id": "wh_ceps8nr7mczvhaqw", "project_id": "proj18pzzkao", "name": "Backend", "url": "https://api.example.com/webhooks/revenuedot", "environment": "production", "event_types": [ "initial_purchase", "renewal" ], "app_id": null, "created_at": 1790801342625, "signing_secret": "whsec_3f5b7fb5a591c17aaa9108376df0bddbe1555f0a908bfdc0" } ``` ### Get a webhook `GET /v2/projects/{project_id}/integrations/webhooks/{webhook_integration_id}` · Auth: secret key or dashboard session · Permissions: `project_configuration:integrations:read` **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | | `webhook_integration_id` | string | yes | Webhook id (wh_...). | **Example request** ```bash curl -s "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/integrations/webhooks/$WEBHOOK_INTEGRATION_ID" -H "Authorization: Bearer $SECRET_KEY" ``` **Responses** - **200**: The webhook. Returns [WebhookIntegration](https://revenuedot.app/docs/api/rest-v2.md#webhookintegration). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). ### Update a webhook `POST /v2/projects/{project_id}/integrations/webhooks/{webhook_integration_id}` · Auth: secret key or dashboard session · Permissions: `project_configuration:integrations:read_write` `enabled` is a RevenueDot extension: false pauses deliveries without deleting the webhook. Events recorded while it is off are not sent; queued retries resume when it is turned on. Read it with `GET /v2/projects/{project_id}/webhooks`. **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | | `webhook_integration_id` | string | yes | Webhook id. | **Request body** (`application/json`) | Field | Type | Required | Description | |---|---|---|---| | `name` | string | no | | | `url` | string | no | | | `authorization_header` | string or null | no | | | `environment` | string or null | no | | | `event_types` | array of string | no | | | `app_id` | string or null | no | | | `enabled` | boolean | no | RevenueDot extension. False pauses deliveries. | **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/integrations/webhooks/$WEBHOOK_INTEGRATION_ID" -H "Authorization: Bearer $SECRET_KEY" \ -H "Content-Type: application/json" -d '{"enabled":false}' ``` **Responses** - **200**: The webhook. Returns [WebhookIntegration](https://revenuedot.app/docs/api/rest-v2.md#webhookintegration). - **400**: The request is invalid. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). ### Delete a webhook `DELETE /v2/projects/{project_id}/integrations/webhooks/{webhook_integration_id}` · Auth: secret key or dashboard session · Permissions: `project_configuration:integrations:read_write` Pending deliveries are deleted with it. **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | | `webhook_integration_id` | string | yes | Webhook id. | **Example request** ```bash curl -s -X DELETE "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/integrations/webhooks/$WEBHOOK_INTEGRATION_ID" -H "Authorization: Bearer $SECRET_KEY" ``` **Responses** - **200**: Deleted. Returns [Deleted](https://revenuedot.app/docs/api/rest-v2.md#deleted). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). Example 200 response: ```json { "object": "webhook_integration", "id": "…", "deleted_at": 1790801342625 } ``` ## Collaborators Dashboard users of the project. ### List collaborators `GET /v2/projects/{project_id}/collaborators` · Auth: secret key or dashboard session · Permissions: `project_configuration:collaborators:read` **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | **Example request** ```bash curl -s "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/collaborators" -H "Authorization: Bearer $SECRET_KEY" ``` **Responses** - **200**: A page of results. Returns a list of [Collaborator](https://revenuedot.app/docs/api/rest-v2.md#collaborator). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/rest-v2.md#v2error). ## Objects The shapes the operations above send and return. ### ActiveEntitlement | Field | Type | Required | Description | |---|---|---|---| | `object` | `"customer.active_entitlement"` | yes | | | `entitlement_id` | string | yes | Entitlement id (entl...), not the lookup key. | | `expires_at` | integer or null | yes | When access ends. Epoch milliseconds, or null. | ### App Only the object for the app's own `type` is present. Store secrets are never returned. | Field | Type | Required | Description | |---|---|---|---| | `object` | `"app"` | yes | | | `id` | string | yes | App id (app...). | | `name` | string | yes | | | `created_at` | integer | yes | Creation time. Epoch milliseconds. | | `type` | `amazon`, `app_store`, `mac_app_store`, `play_store`, `stripe`, `rc_billing`, `roku`, `paddle`, `test_store` | yes | | | `project_id` | string | yes | | | `custom_url_scheme` | string | no | Derived from the public key. | | `app_store` | object | no | | | `app_store.bundle_id` | string | no | | | `app_store.app_store_connect_api_key_configured` | boolean | no | | | `app_store.subscription_key_configured` | boolean | no | True when the in-app purchase key (.p8, key id, issuer id) is set. | | `app_store.app_store_connect_vendor_number` | string or null | no | | | `mac_app_store` | object | no | | | `mac_app_store.bundle_id` | string | no | | | `play_store` | object | no | | | `play_store.package_name` | string | no | | | `play_store.play_service_account_credentials_configured` | boolean | no | | | `amazon` | object | no | | | `amazon.package_name` | string | no | | | `stripe` | object | no | | | `stripe.stripe_account_id` | string or null | no | | | `rc_billing` | object | no | | | `rc_billing.stripe_account_id` | string or null | no | | | `rc_billing.seller_company_name` | string | no | | | `rc_billing.app_name` | string | no | | | `rc_billing.support_email` | string or null | no | | | `rc_billing.default_currency` | string | no | | | `roku` | object | no | | | `roku.roku_channel_id` | string or null | no | | | `roku.roku_channel_name` | string or null | no | | | `paddle` | object | no | | | `paddle.paddle_is_sandbox` | boolean | no | | | `paddle.paddle_api_key` | null | no | | ### Collaborator | Field | Type | Required | Description | |---|---|---|---| | `object` | `"collaborator"` | yes | | | `id` | string | yes | | | `name` | string or null | no | | | `email` | string | yes | | | `role` | `admin`, `developer`, `read_only` | yes | RevenueCat's role names. `read_only` is the dashboard's Viewer role. | | `accepted_at` | integer | no | When the user joined. Epoch milliseconds. | | `has_mfa` | boolean | no | Always false. | ### Customer `active_entitlements` and `experiment` are present on single-customer answers; `attributes` only with `expand=attributes`. | Field | Type | Required | Description | |---|---|---|---| | `object` | `"customer"` | yes | | | `id` | string | yes | The customer's original app user id. | | `project_id` | string | yes | | | `first_seen_at` | integer | yes | First seen. Epoch milliseconds. | | `last_seen_at` | integer or null | yes | Last seen. Epoch milliseconds, or null. | | `last_seen_app_version` | string or null | no | | | `last_seen_country` | string or null | no | | | `last_seen_platform` | string or null | no | | | `last_seen_platform_version` | null | no | | | `active_entitlements` | object | no | | | `active_entitlements.object` | `"list"` | yes | | | `active_entitlements.items` | array of ActiveEntitlement | yes | | | `active_entitlements.next_page` | string or null | yes | Path of the next page, or null on the last page. | | `active_entitlements.url` | string | yes | Path of this list. | | `experiment` | null | no | | | `attributes` | object | no | | | `attributes.object` | `"list"` | yes | | | `attributes.items` | array of CustomerAttribute | yes | | | `attributes.next_page` | string or null | yes | Path of the next page, or null on the last page. | | `attributes.url` | string | yes | Path of this list. | ### CustomerAlias | Field | Type | Required | Description | |---|---|---|---| | `object` | `"customer.alias"` | yes | | | `id` | string | yes | An app user id of the customer. | | `created_at` | integer | yes | When it was linked. Epoch milliseconds. | ### CustomerAttribute | Field | Type | Required | Description | |---|---|---|---| | `object` | `"customer.attribute"` | yes | | | `name` | string | yes | | | `value` | string | yes | | | `updated_at` | integer | yes | Last update. Epoch milliseconds. | ### CustomerEvent | Field | Type | Required | Description | |---|---|---|---| | `object` | `"customer.event"` | yes | | | `id` | string | yes | | | `app_id` | string or null | no | | | `type` | string | yes | Webhook event type, for example INITIAL_PURCHASE. | | `body` | object | yes | The webhook `event` object. | | `created_at` | integer | yes | Recorded. Epoch milliseconds. | | `occurred_at` | integer | yes | When it happened. Epoch milliseconds. | ### Deleted | Field | Type | Required | Description | |---|---|---|---| | `object` | string | yes | The deleted object's type. | | `id` | string | yes | | | `deleted_at` | integer | yes | When it was deleted. Epoch milliseconds. | ### Entitlement | Field | Type | Required | Description | |---|---|---|---| | `object` | `"entitlement"` | yes | | | `id` | string | yes | Entitlement id (entl...). | | `project_id` | string | yes | | | `lookup_key` | string | yes | What apps check, for example `pro`. | | `display_name` | string | yes | | | `created_at` | integer | yes | Creation time. Epoch milliseconds. | | `state` | `active`, `inactive` | yes | | | `products` | object | no | | | `products.object` | `"list"` | yes | | | `products.items` | array of Product | yes | | | `products.next_page` | string or null | yes | Path of the next page, or null on the last page. | | `products.url` | string | yes | Path of this list. | ### IndicativePrice | Field | Type | Required | Description | |---|---|---|---| | `object` | `"indicative_price"` | yes | | | `currency` | string | yes | ISO 4217 code. | | `country` | null | yes | | | `amount_micros` | integer | yes | Price in micros: 9.99 is 9990000. | ### MonetaryAmount | Field | Type | Required | Description | |---|---|---|---| | `currency` | string | yes | ISO 4217 currency code. | | `gross` | number | yes | Gross amount. | | `commission` | number | yes | Estimated store commission. | | `tax` | number | yes | Tax. Always 0 today. | | `proceeds` | number | yes | Gross minus commission. | ### Offering | Field | Type | Required | Description | |---|---|---|---| | `object` | `"offering"` | yes | | | `id` | string | yes | Offering id (ofrng...). | | `lookup_key` | string | yes | | | `display_name` | string | yes | | | `is_current` | boolean | yes | Exactly one offering per project is current. | | `created_at` | integer | yes | Creation time. Epoch milliseconds. | | `project_id` | string | yes | | | `state` | `active`, `inactive` | yes | | | `paywall_id` | null | no | | | `metadata` | object or null | yes | | | `packages` | object | no | | | `packages.object` | `"list"` | yes | | | `packages.items` | array of Package | yes | | | `packages.next_page` | string or null | yes | Path of the next page, or null on the last page. | | `packages.url` | string | yes | Path of this list. | ### OverviewMetrics | Field | Type | Required | Description | |---|---|---|---| | `object` | `"overview_metrics"` | yes | | | `currency` | `"USD"` | yes | | | `metrics` | array of object | yes | | | `metrics[].object` | `"overview_metric"` | no | | | `metrics[].id` | `active_trials`, `active_subscriptions`, `mrr`, `revenue`, `new_customers`, `active_users` | no | | | `metrics[].name` | string | no | | | `metrics[].description` | string | no | | | `metrics[].unit` | `#`, `$` | no | | | `metrics[].period` | `P0D`, `P28D` | no | | | `metrics[].value` | number | no | | | `metrics[].last_updated_at` | integer | no | Computed at. Epoch milliseconds. | | `metrics[].last_updated_at_iso8601` | string | no | | ### Package | Field | Type | Required | Description | |---|---|---|---| | `object` | `"package"` | yes | | | `id` | string | yes | Package id (pkge...). | | `lookup_key` | string | yes | For example $rc_monthly. | | `display_name` | string | yes | | | `position` | integer | yes | Order in the offering, lowest first. | | `created_at` | integer | yes | Creation time. Epoch milliseconds. | | `products` | object | no | | | `products.object` | `"list"` | yes | | | `products.items` | array of PackageProduct | yes | | | `products.next_page` | string or null | yes | Path of the next page, or null on the last page. | | `products.url` | string | yes | Path of this list. | ### PackageProduct | Field | Type | Required | Description | |---|---|---|---| | `product` | Product | yes | | | `eligibility_criteria` | `all`, `google_sdk_lt_6`, `google_sdk_ge_6` | yes | | ### Product | Field | Type | Required | Description | |---|---|---|---| | `object` | `"product"` | yes | | | `id` | string | yes | Product id (prod...). | | `store_identifier` | string | yes | The store's product id. Google Play subscriptions use `subscriptionId:basePlanId`. | | `type` | `subscription`, `one_time`, `consumable`, `non_consumable`, `non_renewing_subscription` | yes | | | `state` | `active`, `inactive` | yes | | | `subscription` | object | no | | | `subscription.duration` | string or null | no | ISO 8601 period such as P1M. | | `subscription.grace_period_duration` | null | no | | | `subscription.trial_duration` | null | no | | | `one_time` | object | no | | | `one_time.is_consumable` | boolean or null | no | | | `created_at` | integer | yes | Creation time. Epoch milliseconds. | | `app_id` | string | yes | | | `display_name` | string or null | yes | | | `app` | App | no | Only the object for the app's own `type` is present. Store secrets are never returned. | | `indicative_price` | IndicativePrice or null | no | With `expand=indicative_price`: the Test Store price, or null. | ### Project | Field | Type | Required | Description | |---|---|---|---| | `object` | `"project"` | yes | | | `id` | string | yes | Project id (proj...). | | `name` | string | yes | | | `created_at` | integer | yes | Creation time. Epoch milliseconds. | | `icon_url` | string or null | no | Always null. | | `icon_url_large` | string or null | no | Always null. | ### PublicApiKey | Field | Type | Required | Description | |---|---|---|---| | `object` | `"public_api_key"` | yes | | | `id` | string | yes | | | `key` | string | yes | The key the SDK sends (appl_, goog_, test_ ...). | | `environment` | `production`, `sandbox` | yes | | | `app_id` | string | yes | | | `created_at` | integer | yes | Creation time. Epoch milliseconds. | ### Purchase | Field | Type | Required | Description | |---|---|---|---| | `object` | `"purchase"` | yes | | | `id` | string | yes | | | `customer_id` | string | yes | | | `original_customer_id` | string | no | | | `product_id` | string | yes | | | `purchased_at` | integer | yes | Purchase time. Epoch milliseconds. | | `revenue_in_usd` | MonetaryAmount | no | | | `quantity` | integer | no | | | `status` | `owned`, `refunded` | yes | | | `presented_offering_id` | string or null | no | Offering the purchase was made from (its id, or the identifier the SDK sent when no such offering exists). | | `entitlements` | object | no | | | `entitlements.object` | `"list"` | yes | | | `entitlements.items` | array of Entitlement | yes | | | `entitlements.next_page` | string or null | yes | Path of the next page, or null on the last page. | | `entitlements.url` | string | yes | Path of this list. | | `environment` | `production`, `sandbox` | yes | | | `store` | string | yes | | | `store_purchase_identifier` | string | no | | | `ownership` | `purchased` | no | | | `country` | string | no | | ### Subscription | Field | Type | Required | Description | |---|---|---|---| | `object` | `"subscription"` | yes | | | `id` | string | yes | Subscription id (sub_...). | | `customer_id` | string | yes | | | `original_customer_id` | string | no | | | `product_id` | string or null | no | Product id (prod...), null for promotional grants. | | `starts_at` | integer | yes | Start of the subscription. Epoch milliseconds. | | `current_period_starts_at` | integer | no | Start of the current period. Epoch milliseconds. | | `current_period_ends_at` | integer or null | no | End of the current period. Epoch milliseconds, or null. | | `ends_at` | integer or null | no | End of access. Epoch milliseconds, or null. | | `gives_access` | boolean | yes | | | `pending_payment` | boolean | no | | | `auto_renewal_status` | `will_renew`, `will_not_renew`, `will_change_product`, `will_pause` | yes | | | `status` | `trialing`, `active`, `in_grace_period`, `in_billing_retry`, `paused`, `expired` | yes | | | `total_revenue_in_usd` | MonetaryAmount | no | | | `presented_offering_id` | string or null | no | Offering the purchase was made from (its id, or the identifier the SDK sent when no such offering exists). | | `entitlements` | object | no | | | `entitlements.object` | `"list"` | yes | | | `entitlements.items` | array of Entitlement | yes | | | `entitlements.next_page` | string or null | yes | Path of the next page, or null on the last page. | | `entitlements.url` | string | yes | Path of this list. | | `environment` | `production`, `sandbox` | yes | | | `store` | string | yes | | | `store_subscription_identifier` | string | no | Latest store transaction id, order id or token. | | `ownership` | `purchased`, `family_shared` | no | | | `country` | string | no | ISO 3166-1 alpha-2, when known. | | `management_url` | null | no | | ### SubscriptionTransaction | Field | Type | Required | Description | |---|---|---|---| | `object` | `"subscription_transaction"` | yes | | | `id` | string | yes | | | `purchased_at` | integer | yes | Purchase time. Epoch milliseconds. | | `product_store_identifier` | string | no | | | `revenue_in_local_currency` | MonetaryAmount or null | no | | | `revenue_in_usd` | MonetaryAmount | no | | | `expiration_date` | integer or null | no | End of the period. Epoch milliseconds, or null. | | `effective_expiration_date` | integer or null | no | When access actually ended (the refund time for a refunded period). Epoch milliseconds, or null. | ### V2Error | Field | Type | Required | Description | |---|---|---|---| | `object` | `"error"` | yes | | | `type` | `parameter_error`, `resource_already_exists`, `resource_missing`, `idempotency_error`, `rate_limit_error`, `authentication_error`, `authorization_error`, `store_error`, `server_error`, `resource_locked_error`, `unprocessable_entity_error`, `invalid_request`, `entity_references_archived_entities` | yes | | | `message` | string | yes | What went wrong. | | `param` | string | no | The request field at fault, when there is one. | | `doc_url` | string | yes | Link to the error's section of the errors page. | | `retryable` | boolean | yes | True when retrying the same request can succeed. | ### WebhookIntegration | Field | Type | Required | Description | |---|---|---|---| | `object` | `"webhook_integration"` | yes | | | `id` | string | yes | Webhook id (wh_...). | | `project_id` | string | yes | | | `name` | string | yes | | | `url` | string | yes | | | `environment` | `production`, `sandbox`, null | yes | Null sends both. | | `event_types` | array of string | yes | Lower-case event types. Empty sends every type. | | `app_id` | string or null | yes | Only events of this app, or null for all. | | `created_at` | integer | yes | Creation time. Epoch milliseconds. | | `signing_secret` | string | no | whsec_... Only in the answer that creates the webhook. | ## Related - [API overview](https://revenuedot.app/docs/api.md) - [Authentication](https://revenuedot.app/docs/api/authentication.md) - [Errors](https://revenuedot.app/docs/api/errors.md) - [OpenAPI document](https://revenuedot.app/docs/api/openapi.yaml) --- # Which API endpoints are RevenueDot extensions? Source: https://revenuedot.app/docs/api/extensions.md Description: RevenueDot-only endpoints: dashboard sign-in, OAuth for MCP clients, project settings, store setup, API keys, webhook deliveries, event log, Test Store, dashboard data and migration import. These endpoints exist only in RevenueDot. They use the same auth, errors and list envelope as [REST API v2](https://revenuedot.app/docs/api/rest-v2.md). The dashboard is built on them, so everything the dashboard does, a script or an AI agent can do too. Base URL: your server, for example `http://localhost:8787` or `https://revenuedot.example.com`. The examples read `REVENUEDOT_URL`, `PUBLIC_KEY`, `SECRET_KEY` and `PROJECT_ID` from your shell. ## Operations on this page (47) - **Dashboard auth**: [Whether sign-up is open](https://revenuedot.app/docs/api/extensions.md#whether-sign-up-is-open), [Create a dashboard account](https://revenuedot.app/docs/api/extensions.md#create-a-dashboard-account), [Sign in](https://revenuedot.app/docs/api/extensions.md#sign-in), [Sign out](https://revenuedot.app/docs/api/extensions.md#sign-out), [The signed-in user and their projects](https://revenuedot.app/docs/api/extensions.md#the-signed-in-user-and-their-projects), [Update account settings](https://revenuedot.app/docs/api/extensions.md#update-account-settings), [Email a password reset link](https://revenuedot.app/docs/api/extensions.md#email-a-password-reset-link), [Check a password reset link](https://revenuedot.app/docs/api/extensions.md#check-a-password-reset-link), [Set a new password from a reset link](https://revenuedot.app/docs/api/extensions.md#set-a-new-password-from-a-reset-link), [Confirm an email address](https://revenuedot.app/docs/api/extensions.md#confirm-an-email-address), [Send a new confirmation email](https://revenuedot.app/docs/api/extensions.md#send-a-new-confirmation-email), [Look up an invite](https://revenuedot.app/docs/api/extensions.md#look-up-an-invite), [Accept an invite](https://revenuedot.app/docs/api/extensions.md#accept-an-invite) - **Members and invites**: [List open invites](https://revenuedot.app/docs/api/extensions.md#list-open-invites), [Invite someone by email](https://revenuedot.app/docs/api/extensions.md#invite-someone-by-email), [Resend an invite](https://revenuedot.app/docs/api/extensions.md#resend-an-invite), [Revoke an invite](https://revenuedot.app/docs/api/extensions.md#revoke-an-invite), [Change a member's role](https://revenuedot.app/docs/api/extensions.md#change-a-members-role), [Remove a member, or leave the project](https://revenuedot.app/docs/api/extensions.md#remove-a-member-or-leave-the-project) - **Project settings**: [Get a project with its settings](https://revenuedot.app/docs/api/extensions.md#get-a-project-with-its-settings), [Update a project's name and transfer behaviour](https://revenuedot.app/docs/api/extensions.md#update-a-projects-name-and-transfer-behaviour), [Delete a project and everything in it](https://revenuedot.app/docs/api/extensions.md#delete-a-project-and-everything-in-it) - **Store setup**: [Store setup state of an app](https://revenuedot.app/docs/api/extensions.md#store-setup-state-of-an-app), [Check store credentials with Apple or Google](https://revenuedot.app/docs/api/extensions.md#check-store-credentials-with-apple-or-google), [Extend every active App Store subscriber of a product](https://revenuedot.app/docs/api/extensions.md#extend-every-active-app-store-subscriber-of-a-product), [Status of a mass extension](https://revenuedot.app/docs/api/extensions.md#status-of-a-mass-extension), [Setup health](https://revenuedot.app/docs/api/extensions.md#setup-health) - **API keys**: [List secret keys](https://revenuedot.app/docs/api/extensions.md#list-secret-keys), [Create a secret key](https://revenuedot.app/docs/api/extensions.md#create-a-secret-key), [Delete a secret key](https://revenuedot.app/docs/api/extensions.md#delete-a-secret-key) - **Webhook deliveries**: [Send a TEST event to one webhook](https://revenuedot.app/docs/api/extensions.md#send-a-test-event-to-one-webhook), [Whether each webhook is enabled](https://revenuedot.app/docs/api/extensions.md#whether-each-webhook-is-enabled), [Delivery log of a webhook](https://revenuedot.app/docs/api/extensions.md#delivery-log-of-a-webhook), [Retry a delivery now](https://revenuedot.app/docs/api/extensions.md#retry-a-delivery-now) - **Event log**: [Event log](https://revenuedot.app/docs/api/extensions.md#event-log), [Transaction feed](https://revenuedot.app/docs/api/extensions.md#transaction-feed) - **Test Store**: [Simulate a Test Store purchase or lifecycle](https://revenuedot.app/docs/api/extensions.md#simulate-a-test-store-purchase-or-lifecycle) - **Dashboard data**: [Daily history of an overview metric](https://revenuedot.app/docs/api/extensions.md#daily-history-of-an-overview-metric), [Dashboard rows for customers](https://revenuedot.app/docs/api/extensions.md#dashboard-rows-for-customers) - **Migration import**: [Import customers with their purchases](https://revenuedot.app/docs/api/extensions.md#import-customers-with-their-purchases), [Keep an app's existing SDK key](https://revenuedot.app/docs/api/extensions.md#keep-an-apps-existing-sdk-key), [What still needs attention after an import](https://revenuedot.app/docs/api/extensions.md#what-still-needs-attention-after-an-import) - **OAuth for MCP clients**: [OAuth authorization server metadata](https://revenuedot.app/docs/api/extensions.md#oauth-authorization-server-metadata), [Register an OAuth client](https://revenuedot.app/docs/api/extensions.md#register-an-oauth-client), [Consent screen](https://revenuedot.app/docs/api/extensions.md#consent-screen), [Submit the consent decision](https://revenuedot.app/docs/api/extensions.md#submit-the-consent-decision), [Exchange a code for an access token](https://revenuedot.app/docs/api/extensions.md#exchange-a-code-for-an-access-token) ## Dashboard auth Sign-up, sign-in, password reset, email confirmation, invites and account settings for the dashboard. The session cookie also authorizes REST API v2. ### Whether sign-up is open `GET /auth/config` · Auth: none · RevenueDot extension What the sign-in page needs before it shows a form. **Example request** ```bash curl -s "$REVENUEDOT_URL/auth/config" ``` **Responses** - **200**: The config. Example 200 response: ```json { "edition": "self-hosted", "signup": "closed" } ``` ### Create a dashboard account `POST /auth/signup` · Auth: none · RevenueDot extension Creates the user and a first project, and sets the `rd_session` cookie (30 days; `Secure` over https). On a self-hosted server only the first account (the owner) can sign up, unless the server runs with `REVENUEDOT_ALLOW_SIGNUP=true`. With `invite_token` (from an invite link), the account joins the inviting project instead of getting a new one, and sign-up works even where it is closed. The email must be the invited address; the account counts as verified. On RevenueDot Cloud, an account without an invite gets an email with a confirmation link (valid 24 hours). **Request body** (`application/json`) | Field | Type | Required | Description | |---|---|---|---| | `email` | string | yes | | | `password` | string | yes | | | `name` | string | no | | | `project_name` | string | no | Default: My project. Ignored with `invite_token`. | | `invite_token` | string | no | RevenueDot extension. The token from an invite link (`/invite?token=...`). | **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/auth/signup" \ -H "Content-Type: application/json" -d '{"email":"dev@example.com","password":"change-me-please","project_name":"My app"}' ``` **Responses** - **201**: Signed up and signed in. With an invite, `project_id` is the project joined. - **400**: Invalid email or password, or an invite that is not valid (`invite_invalid`) or for another address (`invite_email_mismatch`). - **403**: Sign-up is closed: the server has an owner already. - **409**: The email is taken. Example 201 response: ```json { "ok": true } ``` ### Sign in `POST /auth/login` · Auth: none · RevenueDot extension **Request body** (`application/json`) | Field | Type | Required | Description | |---|---|---|---| | `email` | string | yes | | | `password` | string | yes | | **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/auth/login" ``` **Responses** - **200**: Signed in; `rd_session` is set. - **400**: Missing fields. - **401**: Wrong email or password. Example 200 response: ```json { "ok": true } ``` ### Sign out `POST /auth/logout` · Auth: dashboard session · RevenueDot extension **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/auth/logout" ``` **Responses** - **200**: Signed out. Example 200 response: ```json { "ok": true } ``` ### The signed-in user and their projects `GET /auth/me` · Auth: dashboard session · RevenueDot extension **Example request** ```bash curl -s "$REVENUEDOT_URL/auth/me" ``` **Responses** - **200**: The user. - **401**: Not signed in. Example 200 response: ```json { "user": { "id": "usr_8k2m4q", "email": "dev@example.com", "name": "Dana", "email_verified": true, "alert_emails": true }, "account": { "edition": "cloud", "plan": "free", "email_verification_required": false }, "projects": [] } ``` ### Update account settings `POST /auth/me` · Auth: dashboard session · RevenueDot extension The display name and whether the user gets [alert emails](https://revenuedot.app/docs/guides/alerts.md) for projects they administer. Send only the fields to change. A null or empty `name` clears it. **Request body** (`application/json`) | Field | Type | Required | Description | |---|---|---|---| | `name` | string or null | no | | | `alert_emails` | boolean | no | False stops alert emails for every project. | **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/auth/me" \ -H "Content-Type: application/json" -d '{"alert_emails":false}' ``` **Responses** - **200**: The updated user. - **400**: Invalid field. - **401**: Not signed in. Example 200 response: ```json { "user": { "id": "usr_8k2m4q", "email": "dev@example.com", "name": "Dana", "email_verified": true, "alert_emails": false } } ``` ### Email a password reset link `POST /auth/password/forgot` · Auth: none · RevenueDot extension Always answers 200 with the same body, whether or not an account uses the address, so the answer does not reveal who has an account. If one does, it gets a link to `/reset-password` that works once and expires after 1 hour. Limits: 5 requests per IP address per 15 minutes (then 429), and 3 emails per address per hour (further requests answer 200 but send nothing). See [I forgot my password](https://revenuedot.app/docs/help/forgot-password.md). **Request body** (`application/json`) | Field | Type | Required | Description | |---|---|---|---| | `email` | string | yes | | **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/auth/password/forgot" \ -H "Content-Type: application/json" -d '{"email":"dev@example.com"}' ``` **Responses** - **200**: Accepted. - **400**: Not a valid email address. - **429**: Too many requests from this IP address. Example 200 response: ```json { "ok": true, "message": "If an account uses this email, we sent it a link to reset the password. The link expires in 1 hour." } ``` ### Check a password reset link `POST /auth/password/check` · Auth: none · RevenueDot extension Tells the reset page whether the link still works before the user types a new password. Does not use up the link. **Request body** (`application/json`) | Field | Type | Required | Description | |---|---|---|---| | `token` | string | yes | The `token` from the reset link. | **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/auth/password/check" ``` **Responses** - **200**: Whether the link works. - **400**: Missing token. Example 200 response: ```json { "valid": false, "reason": "expired", "message": "This link has expired. Ask for a new one." } ``` ### Set a new password from a reset link `POST /auth/password/reset` · Auth: none · RevenueDot extension Sets the password, signs the user out on every device, marks the email as confirmed (the link proved the inbox) and signs this browser in with a new `rd_session` cookie. Every other open reset link of the user stops working. **Request body** (`application/json`) | Field | Type | Required | Description | |---|---|---|---| | `token` | string | yes | | | `password` | string | yes | | **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/auth/password/reset" \ -H "Content-Type: application/json" -d '{"token":"…","password":"a-new-long-password"}' ``` **Responses** - **200**: Password changed and signed in. - **400**: The password is too short or too long, or the link is not valid (`token_invalid` with a `reason`). Example 200 response: ```json { "ok": true } ``` ### Confirm an email address `POST /auth/email/verify` · Auth: none · RevenueDot extension RevenueDot Cloud only: the link in the confirmation email sent at sign-up (valid 24 hours, works once). Self-hosted servers treat every account as confirmed. **Request body** (`application/json`) | Field | Type | Required | Description | |---|---|---|---| | `token` | string | yes | The `token` from the confirmation link. | **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/auth/email/verify" ``` **Responses** - **200**: Confirmed. - **400**: The link is not valid (`token_invalid` with a `reason`). Example 200 response: ```json { "ok": true, "email": "dev@example.com" } ``` ### Send a new confirmation email `POST /auth/email/verify/resend` · Auth: dashboard session · RevenueDot extension Up to 5 per user per hour. An account that is already confirmed gets `already_verified: true` and no email. **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/auth/email/verify/resend" ``` **Responses** - **200**: Sent, or already confirmed. - **401**: Not signed in. - **429**: Too many emails this hour. - **502**: The mail server did not accept the email. Example 200 response: ```json { "ok": true, "email": "dev@example.com" } ``` ### Look up an invite `GET /auth/invites/{token}` · Auth: none · RevenueDot extension What the invite page shows: the project, the role, who sent it and whether the invited address has an account already (sign in and accept, or sign up with `invite_token`). **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `token` | string | yes | The `token` from the invite link (`/invite?token=...`). | **Example request** ```bash curl -s "$REVENUEDOT_URL/auth/invites/$TOKEN" ``` **Responses** - **200**: The invite. - **404**: Not valid, expired, already accepted or revoked. Example 200 response: ```json { "object": "invite", "email": "sam@example.com", "role": "developer", "project": { "id": "proj18pzzkao", "name": "My app" }, "invited_by": { "name": "Dana", "email": "dev@example.com" }, "expires_at": 1791405714000, "account_exists": false } ``` ### Accept an invite `POST /auth/invites/{token}/accept` · Auth: dashboard session · RevenueDot extension For a user who already has an account, signed in with the invited address. Adds them to the project with the invite's role; someone who is already a member keeps their role. Also marks their email as confirmed. **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `token` | string | yes | The `token` from the invite link (`/invite?token=...`). | **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/auth/invites/$TOKEN/accept" ``` **Responses** - **200**: Joined. - **401**: Not signed in. - **403**: Signed in with another address. - **404**: The invite is no longer valid. Example 200 response: ```json { "ok": true, "project_id": "proj18pzzkao" } ``` ## Members and invites Invite people to a project by email, change their role, remove them. Dashboard session only. ### List open invites `GET /v2/projects/{project_id}/invites` · Auth: dashboard session · RevenueDot extension · Permissions: `project_configuration:collaborators:read` Invites nobody has accepted or revoked, oldest first. Expired ones stay listed so an admin can resend them. Needs a dashboard session: secret API keys cannot manage members. **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | **Example request** ```bash curl -s "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/invites" ``` **Responses** - **200**: Open invites. Returns a list of [Invite](https://revenuedot.app/docs/api/extensions.md#invite). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). Example 200 response: ```json { "object": "list", "items": [ { "object": "invite", "id": "inv_4f8k2m9q1x7z", "email": "sam@example.com", "role": "developer", "status": "pending", "invited_by": "usr_8k2m4q", "created_at": 1790800914012, "last_sent_at": 1790800914012, "expires_at": 1791405714012 } ], "next_page": null, "url": "/v2/projects/proj18pzzkao/invites" } ``` ### Invite someone by email `POST /v2/projects/{project_id}/invites` · Auth: dashboard session · RevenueDot extension Admins only. Emails a link that lasts 7 days. Inviting an address that already has an open invite replaces it: the role changes, a new link goes out and the old one stops working. See [Invite your team](https://revenuedot.app/docs/guides/team.md). On RevenueDot Cloud the admin needs a confirmed email address. A project can send 50 invites (including resends) per day; after that the answer is 429. `email_sent` is false when the mail server refused the email; the invite still exists and can be resent. **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | **Request body** (`application/json`) | Field | Type | Required | Description | |---|---|---|---| | `email` | string | yes | | | `role` | `admin`, `developer`, `viewer` | yes | | **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/invites" \ -H "Content-Type: application/json" -d '{"email":"sam@example.com","role":"developer"}' ``` **Responses** - **201**: The invite. - **400**: The request is invalid. Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). - **409**: It already exists, or it conflicts with another object. Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). - **429**: Too many requests. Retry later. Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). Example 201 response: ```json { "object": "invite", "id": "inv_4f8k2m9q1x7z", "email": "sam@example.com", "role": "developer", "status": "pending", "invited_by": "usr_8k2m4q", "created_at": 1790800914012, "last_sent_at": 1790800914012, "expires_at": 1791405714012, "email_sent": true } ``` ### Resend an invite `POST /v2/projects/{project_id}/invites/{invite_id}/actions/resend` · Auth: dashboard session · RevenueDot extension Admins only. Sends a new link valid for 7 more days; the old link stops working. Works on expired invites. Counts toward the 50 invites per project per day. **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | | `invite_id` | string | yes | Invite id (inv_...). | **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/invites/$INVITE_ID/actions/resend" ``` **Responses** - **200**: The invite. - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). - **429**: Too many requests. Retry later. Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). ### Revoke an invite `DELETE /v2/projects/{project_id}/invites/{invite_id}` · Auth: dashboard session · RevenueDot extension Admins only. The link stops working at once. **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | | `invite_id` | string | yes | Invite id (inv_...). | **Example request** ```bash curl -s -X DELETE "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/invites/$INVITE_ID" ``` **Responses** - **200**: Deleted. Returns [Deleted](https://revenuedot.app/docs/api/extensions.md#deleted). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). Example 200 response: ```json { "object": "invite", "id": "…", "deleted_at": 1790801342625 } ``` ### Change a member's role `POST /v2/projects/{project_id}/collaborators/{user_id}` · Auth: dashboard session · RevenueDot extension Admins only. A project always keeps at least one admin, so the last admin cannot be demoted (400). The response uses RevenueCat's role names: `viewer` comes back as `read_only`. **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | | `user_id` | string | yes | The member's user id (the collaborator `id`). | **Request body** (`application/json`) | Field | Type | Required | Description | |---|---|---|---| | `role` | `admin`, `developer`, `viewer` | yes | | **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/collaborators/$USER_ID" \ -H "Content-Type: application/json" -d '{"role":"viewer"}' ``` **Responses** - **200**: The member. Returns [Collaborator](https://revenuedot.app/docs/api/extensions.md#collaborator). - **400**: The request is invalid. Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). Example 200 response: ```json { "object": "collaborator", "id": "usr_3n7p1x", "name": "Sam", "email": "sam@example.com", "role": "read_only", "accepted_at": 1790800914012, "has_mfa": false } ``` ### Remove a member, or leave the project `DELETE /v2/projects/{project_id}/collaborators/{user_id}` · Auth: dashboard session · RevenueDot extension Any member can remove themselves. Removing someone else takes an admin. The last admin cannot leave or be removed (422): make someone else an admin first, or delete the project. **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | | `user_id` | string | yes | The member's user id. Your own id leaves the project. | **Example request** ```bash curl -s -X DELETE "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/collaborators/$USER_ID" ``` **Responses** - **200**: Deleted. Returns [Deleted](https://revenuedot.app/docs/api/extensions.md#deleted). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). - **422**: The request is valid but cannot be done in this state or for this store. Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). Example 200 response: ```json { "object": "collaborator", "id": "…", "deleted_at": 1790801342625 } ``` ## Project settings Project name, transfer behaviour and deletion. ### Get a project with its settings `GET /v2/projects/{project_id}` · Auth: secret key or dashboard session · RevenueDot extension · Permissions: `project_configuration:projects:read` **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | **Example request** ```bash curl -s "$REVENUEDOT_URL/v2/projects/$PROJECT_ID" -H "Authorization: Bearer $SECRET_KEY" ``` **Responses** - **200**: The project. Returns [ProjectSettings](https://revenuedot.app/docs/api/extensions.md#projectsettings). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). Example 200 response: ```json { "object": "project", "id": "proj18pzzkao", "name": "My app", "created_at": 1790800900675, "icon_url": null, "icon_url_large": null, "transfer_behavior": "transfer", "sandbox_transfer_behavior": null } ``` ### Update a project's name and transfer behaviour `POST /v2/projects/{project_id}` · Auth: secret key or dashboard session · RevenueDot extension · Permissions: `project_configuration:projects:read_write` See [who owns a restored purchase](https://revenuedot.app/docs/concepts/customers-and-app-user-ids.md#who-owns-a-restored-purchase). **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | **Request body** (`application/json`) | Field | Type | Required | Description | |---|---|---|---| | `name` | string | no | | | `transfer_behavior` | `transfer`, `transfer_if_no_active`, `keep`, `share` | no | | | `sandbox_transfer_behavior` | `transfer`, `transfer_if_no_active`, `keep`, `share`, null | no | | **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/v2/projects/$PROJECT_ID" -H "Authorization: Bearer $SECRET_KEY" \ -H "Content-Type: application/json" -d '{"transfer_behavior":"transfer_if_no_active"}' ``` **Responses** - **200**: The project. Returns [ProjectSettings](https://revenuedot.app/docs/api/extensions.md#projectsettings). - **400**: The request is invalid. Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). ### Delete a project and everything in it `DELETE /v2/projects/{project_id}` · Auth: dashboard session · RevenueDot extension · Permissions: `project_configuration:projects:read_write` Only a project admin signed in to the dashboard can do this. Apps, catalog, customers, purchases, events and webhooks are deleted. Cannot be undone. **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | **Example request** ```bash curl -s -X DELETE "$REVENUEDOT_URL/v2/projects/$PROJECT_ID" ``` **Responses** - **200**: Deleted. Returns [Deleted](https://revenuedot.app/docs/api/extensions.md#deleted). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). Example 200 response: ```json { "object": "project", "id": "…", "deleted_at": 1790801342625 } ``` ## Store setup Notification URLs, credential checks, setup health and App Store mass extensions. ### Store setup state of an app `GET /v2/projects/{project_id}/apps/{app_id}/store_settings` · Auth: secret key or dashboard session · RevenueDot extension · Permissions: `project_configuration:apps:read` The notification URL to paste into App Store Connect or Pub/Sub, the notification status, the forwarding URL and which credentials are set. Never a secret. **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | | `app_id` | string | yes | App id (app...). | **Example request** ```bash curl -s "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/apps/$APP_ID/store_settings" -H "Authorization: Bearer $SECRET_KEY" ``` **Responses** - **200**: The settings. Returns [StoreSettings](https://revenuedot.app/docs/api/extensions.md#storesettings). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). ### Check store credentials with Apple or Google `POST /v2/projects/{project_id}/apps/{app_id}/actions/verify_credentials` · Auth: secret key or dashboard session · RevenueDot extension · Permissions: `project_configuration:apps:read` Makes one harmless call to the App Store Server API or the Play Developer API. Values in the body are checked before you save them; missing values fall back to the saved ones. **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | | `app_id` | string | yes | App id (app...). | **Request body** (`application/json`) | Field | Type | Required | Description | |---|---|---|---| | `app_store` | object | no | | | `app_store.bundle_id` | string or null | no | | | `app_store.subscription_private_key` | string or null | no | | | `app_store.subscription_key_id` | string or null | no | | | `app_store.subscription_key_issuer` | string or null | no | | | `mac_app_store` | object | no | | | `mac_app_store.bundle_id` | string or null | no | | | `mac_app_store.subscription_private_key` | string or null | no | | | `mac_app_store.subscription_key_id` | string or null | no | | | `mac_app_store.subscription_key_issuer` | string or null | no | | | `play_store` | object | no | | | `play_store.package_name` | string or null | no | | | `play_store.play_service_account_credentials_json` | string or object or null | no | | **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/apps/$APP_ID/actions/verify_credentials" -H "Authorization: Bearer $SECRET_KEY" \ -H "Content-Type: application/json" -d '{}' ``` **Responses** - **200**: The result. Returns [CredentialsCheck](https://revenuedot.app/docs/api/extensions.md#credentialscheck). - **400**: The request is invalid. Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). Example 200 response: ```json { "object": "credentials_check", "app_id": "appugfw01uy", "store": "app_store", "status": "invalid", "valid": false, "message": "No in-app purchase key yet. Add the .p8 file, the key ID and the issuer ID.", "checked_at": 1790801342700 } ``` ### Extend every active App Store subscriber of a product `POST /v2/projects/{project_id}/apps/{app_id}/actions/mass_extend` · Auth: secret key or dashboard session · RevenueDot extension · Permissions: `customer_information:subscriptions:read_write` Asks Apple to extend renewal dates for all active subscribers of `product_id`. Apple then sends one notification per subscription, which records SUBSCRIPTION_EXTENDED. **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | | `app_id` | string | yes | App id (app...). | **Request body** (`application/json`) | Field | Type | Required | Description | |---|---|---|---| | `product_id` | string | yes | | | `extend_by_days` | integer | yes | | | `extend_reason_code` | `undeclared`, `customer_satisfaction`, `other`, `service_issue_or_outage` | yes | | | `storefront_country_codes` | array of string | no | | | `environment` | `production`, `sandbox` | no | | **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/apps/$APP_ID/actions/mass_extend" -H "Authorization: Bearer $SECRET_KEY" \ -H "Content-Type: application/json" -d '{"product_id":"pro_monthly","extend_by_days":3,"extend_reason_code":"service_issue_or_outage"}' ``` **Responses** - **202**: Accepted by Apple. Returns [MassExtension](https://revenuedot.app/docs/api/extensions.md#massextension). - **400**: The request is invalid. Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). - **422**: The request is valid but cannot be done in this state or for this store. Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). - **503**: The store could not be reached. Retry later. Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). ### Status of a mass extension `GET /v2/projects/{project_id}/apps/{app_id}/mass_extensions/{request_id}` · Auth: secret key or dashboard session · RevenueDot extension · Permissions: `customer_information:subscriptions:read` **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | | `app_id` | string | yes | App id (app...). | | `request_id` | string | yes | The `id` from the mass extend answer. | **Query parameters** | Name | Type | Required | Description | |---|---|---|---| | `product_id` | string | yes | | | `environment` | `production`, `sandbox` | no | | **Example request** ```bash curl -s "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/apps/$APP_ID/mass_extensions/$REQUEST_ID" -H "Authorization: Bearer $SECRET_KEY" ``` **Responses** - **200**: The status. Returns [MassExtension](https://revenuedot.app/docs/api/extensions.md#massextension). - **400**: The request is invalid. Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). - **422**: The request is valid but cannot be done in this state or for this store. Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). - **503**: The store could not be reached. Retry later. Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). ### Setup health `GET /v2/projects/{project_id}/setup_health` · Auth: secret key or dashboard session · RevenueDot extension · Permissions: `project_configuration:apps:read` Per app: the notification URL and whether notifications arrive. For webhooks: deliveries in the last 24 hours and failing endpoints. Also the SDK versions calling the server. **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | **Example request** ```bash curl -s "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/setup_health" -H "Authorization: Bearer $SECRET_KEY" ``` **Responses** - **200**: Setup health. Returns [SetupHealth](https://revenuedot.app/docs/api/extensions.md#setuphealth). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). ## API keys Secret keys for the REST API. ### List secret keys `GET /v2/projects/{project_id}/api_keys` · Auth: secret key or dashboard session · RevenueDot extension · Permissions: `project_configuration:api_keys:read` Never returns the key itself. **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | **Query parameters** | Name | Type | Required | Description | |---|---|---|---| | `limit` | integer | no | Page size. Values outside 1-100 are clamped, not rejected. | | `starting_after` | string | no | Id of the last item of the previous page. Use `next_page` instead of building it. | **Example request** ```bash curl -s "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/api_keys" -H "Authorization: Bearer $SECRET_KEY" ``` **Responses** - **200**: A page of results. Returns a list of [ApiKey](https://revenuedot.app/docs/api/extensions.md#apikey). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). ### Create a secret key `POST /v2/projects/{project_id}/api_keys` · Auth: secret key or dashboard session · RevenueDot extension · Permissions: `project_configuration:api_keys:read_write` The answer includes `key` once. `permissions` default to `["*"]`. A key cannot create a key with permissions it does not hold. **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | **Request body** (`application/json`) | Field | Type | Required | Description | |---|---|---|---| | `name` | string | yes | | | `permissions` | array of string | no | | **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/api_keys" -H "Authorization: Bearer $SECRET_KEY" \ -H "Content-Type: application/json" -d '{"name":"Backend (read only)","permissions":["customer_information:customers:read"]}' ``` **Responses** - **201**: The key. Returns [ApiKey](https://revenuedot.app/docs/api/extensions.md#apikey). - **400**: The request is invalid. Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). Example 201 response: ```json { "object": "api_key", "id": "key_08ec817fce", "name": "Backend (read only)", "prefix": "sk_08ec", "permissions": [ "customer_information:customers:read" ], "created_at": 1790801342634, "last_used_at": null, "key": "sk_08ec817fceead27005772bb943c2bd225850eb931c9835f0" } ``` ### Delete a secret key `DELETE /v2/projects/{project_id}/api_keys/{key_id}` · Auth: secret key or dashboard session · RevenueDot extension · Permissions: `project_configuration:api_keys:read_write` **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | | `key_id` | string | yes | Key id (key_...). | **Example request** ```bash curl -s -X DELETE "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/api_keys/$KEY_ID" -H "Authorization: Bearer $SECRET_KEY" ``` **Responses** - **200**: Deleted. Returns [Deleted](https://revenuedot.app/docs/api/extensions.md#deleted). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). Example 200 response: ```json { "object": "api_key", "id": "…", "deleted_at": 1790801342625 } ``` ## Webhook deliveries Delivery log, manual retry and test events. ### Send a TEST event to one webhook `POST /v2/projects/{project_id}/integrations/webhooks/{webhook_integration_id}/test` · Auth: secret key or dashboard session · RevenueDot extension · Permissions: `project_configuration:integrations:read_write` Queues a purchase-shaped TEST event, signed and retried like any delivery. The webhook's filters do not apply. A paused webhook (`enabled` false) answers 422. **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | | `webhook_integration_id` | string | yes | Webhook id. | **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/integrations/webhooks/$WEBHOOK_INTEGRATION_ID/test" -H "Authorization: Bearer $SECRET_KEY" ``` **Responses** - **201**: The queued delivery. Returns [WebhookDelivery](https://revenuedot.app/docs/api/extensions.md#webhookdelivery). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). - **422**: The request is valid but cannot be done in this state or for this store. Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). ### Whether each webhook is enabled `GET /v2/projects/{project_id}/webhooks` · Auth: secret key or dashboard session · RevenueDot extension · Permissions: `project_configuration:integrations:read` RevenueCat's webhook object has no `enabled` field, so it is read here. Set it with `POST .../integrations/webhooks/{id}`. **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | **Example request** ```bash curl -s "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/webhooks" -H "Authorization: Bearer $SECRET_KEY" ``` **Responses** - **200**: A page of results. Returns a list of [WebhookState](https://revenuedot.app/docs/api/extensions.md#webhookstate). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). ### Delivery log of a webhook `GET /v2/projects/{project_id}/webhooks/{webhook_id}/deliveries` · Auth: secret key or dashboard session · RevenueDot extension · Permissions: `project_configuration:integrations:read` Newest first. **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | | `webhook_id` | string | yes | Webhook id (wh_...). | **Query parameters** | Name | Type | Required | Description | |---|---|---|---| | `status` | `pending`, `delivered`, `failed` | no | | | `limit` | integer | no | Page size. Values outside 1-100 are clamped, not rejected. | | `starting_after` | string | no | Id of the last item of the previous page. Use `next_page` instead of building it. | **Example request** ```bash curl -s "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/webhooks/$WEBHOOK_ID/deliveries" -H "Authorization: Bearer $SECRET_KEY" ``` **Responses** - **200**: A page of results. Returns a list of [WebhookDelivery](https://revenuedot.app/docs/api/extensions.md#webhookdelivery). - **400**: The request is invalid. Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). ### Retry a delivery now `POST /v2/projects/{project_id}/webhooks/{webhook_id}/deliveries/{delivery_id}/retry` · Auth: secret key or dashboard session · RevenueDot extension · Permissions: `project_configuration:integrations:read_write` **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | | `webhook_id` | string | yes | Webhook id. | | `delivery_id` | string | yes | Delivery id. | **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/webhooks/$WEBHOOK_ID/deliveries/$DELIVERY_ID/retry" -H "Authorization: Bearer $SECRET_KEY" ``` **Responses** - **200**: The delivery, queued. Returns [WebhookDelivery](https://revenuedot.app/docs/api/extensions.md#webhookdelivery). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). ## Event log Every recorded event and money movement. ### Event log `GET /v2/projects/{project_id}/events` · Auth: secret key or dashboard session · RevenueDot extension · Permissions: `customer_information:customers:read` Every event the project recorded, newest first. `body` is exactly what webhooks receive. **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | **Query parameters** | Name | Type | Required | Description | |---|---|---|---| | `type` | array of string | no | Event types (any case); repeat or comma-separate. | | `customer` | string | no | Any app user id of the customer. | | `environment` | `production`, `sandbox` | no | Only this environment. Default: both. | | `limit` | integer | no | Page size. Values outside 1-100 are clamped, not rejected. | | `starting_after` | string | no | Id of the last item of the previous page. Use `next_page` instead of building it. | **Example request** ```bash curl -s "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/events" -H "Authorization: Bearer $SECRET_KEY" ``` **Responses** - **200**: A page of results. Returns a list of [Event](https://revenuedot.app/docs/api/extensions.md#event). - **400**: The request is invalid. Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). ### Transaction feed `GET /v2/projects/{project_id}/transactions` · Auth: secret key or dashboard session · RevenueDot extension · Permissions: `customer_information:purchases:read` Every purchase, renewal, trial start, refund and refund reversal, newest first. **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | **Query parameters** | Name | Type | Required | Description | |---|---|---|---| | `customer` | string | no | Any app user id of the customer. | | `environment` | `production`, `sandbox` | no | Only this environment. Default: both. | | `limit` | integer | no | Page size. Values outside 1-100 are clamped, not rejected. | | `starting_after` | string | no | Id of the last item of the previous page. Use `next_page` instead of building it. | **Example request** ```bash curl -s "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/transactions" -H "Authorization: Bearer $SECRET_KEY" ``` **Responses** - **200**: A page of results. Returns a list of [Transaction](https://revenuedot.app/docs/api/extensions.md#transaction). - **400**: The request is invalid. Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). ## Test Store Simulated purchases and lifecycles for development. ### Simulate a Test Store purchase or lifecycle `POST /v2/projects/{project_id}/test_purchases` · Auth: secret key or dashboard session · RevenueDot extension · Permissions: `customer_information:purchases:read_write` Runs a purchase through the same pipeline as an SDK receipt, so events, the transaction ledger and webhooks come out as they would. Scenarios: `purchase`, `trial`, `trial_conversion`, `renewal`, `cancel`, `billing_issue`, `refund`, `expire`. See [the Test Store guide](https://revenuedot.app/docs/guides/test-store.md). **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | **Request body** (`application/json`) | Field | Type | Required | Description | |---|---|---|---| | `app_user_id` | string | yes | | | `product_id` | string | yes | Product id or store identifier of a Test Store product. | | `app_id` | string | no | Test Store app; default the project's first. | | `price` | number | no | | | `currency` | string | no | Three letters; default USD. | | `purchased_at` | integer | no | Start, epoch milliseconds. Not with offset_days. | | `presented_offering_id` | string | no | | | `scenario` | `purchase`, `trial`, `trial_conversion`, `renewal`, `cancel`, `billing_issue`, `refund`, `expire` | no | | | `offset_days` | number | no | Days ago the scenario starts (0 to 730). | | `country_code` | string | no | ISO 3166-1 alpha-2, upper case. | **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/test_purchases" -H "Authorization: Bearer $SECRET_KEY" \ -H "Content-Type: application/json" -d '{"app_user_id":"user_renewal","product_id":"pro_monthly","scenario":"renewal","price":9.99}' ``` **Responses** - **201**: What happened. Returns [TestPurchase](https://revenuedot.app/docs/api/extensions.md#testpurchase). - **400**: The request is invalid. Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). - **422**: The request is valid but cannot be done in this state or for this store. Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). ## Dashboard data Series and rows the dashboard shows. ### Daily history of an overview metric `GET /v2/projects/{project_id}/metrics/history` · Auth: secret key or dashboard session · RevenueDot extension · Permissions: `charts_metrics:overview:read` **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | **Query parameters** | Name | Type | Required | Description | |---|---|---|---| | `metric` | `active_trials`, `active_subscriptions`, `mrr`, `revenue`, `new_customers`, `active_users` | yes | | | `days` | integer | no | | | `environment` | `production`, `sandbox` | no | | **Example request** ```bash curl -s "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/metrics/history" -H "Authorization: Bearer $SECRET_KEY" ``` **Responses** - **200**: The history. Returns [MetricHistory](https://revenuedot.app/docs/api/extensions.md#metrichistory). - **400**: The request is invalid. Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). ### Dashboard rows for customers `GET /v2/projects/{project_id}/customer_summaries` · Auth: secret key or dashboard session · RevenueDot extension · Permissions: `customer_information:customers:read` Revenue, entitlement names and prices per customer. Unknown ids are left out. **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | **Query parameters** | Name | Type | Required | Description | |---|---|---|---| | `ids` | string | yes | Up to 100 app user ids, comma separated or repeated. | **Example request** ```bash curl -s "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/customer_summaries" -H "Authorization: Bearer $SECRET_KEY" ``` **Responses** - **200**: A page of results. Returns a list of [CustomerSummary](https://revenuedot.app/docs/api/extensions.md#customersummary). - **400**: The request is invalid. Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). ## Migration import Bulk import from RevenueCat, used by the `revenuedot import` CLI. ### Import customers with their purchases `POST /v2/projects/{project_id}/import/customers` · Auth: secret key or dashboard session · RevenueDot extension · Permissions: `customer_information:customers:read_write` Up to 100 RevenueCat-shaped customers per call, each with aliases, attributes, subscriptions and one-time purchases. Writes state directly: no events and no webhooks unless `emit_events` is true. Keeps first-seen dates, original purchase dates and store transaction ids, and keys each subscription like the store adapters do (Apple original transaction id, Google purchase token), so later receipts and notifications update the imported row. Running the same import twice changes nothing. Google subscriptions without `purchase_token` are keyed `needs_token_refresh:` until a token is found. The `revenuedot import` CLI calls this; see [the importer](https://revenuedot.app/docs/migrate/importer.md). **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | **Request body** (`application/json`) | Field | Type | Required | Description | |---|---|---|---| | `customers` | array of object | yes | | | `customers[].id` | string | yes | | | `customers[].aliases` | array of string | no | | | `customers[].first_seen_at` | integer | no | | | `customers[].last_seen_at` | integer | no | | | `customers[].last_seen_app_version` | string or null | no | | | `customers[].last_seen_country` | string or null | no | | | `customers[].last_seen_platform` | string or null | no | | | `customers[].attributes` | array of object | no | | | `customers[].attributes[].name` | string | yes | | | `customers[].attributes[].value` | string or null | yes | | | `customers[].attributes[].updated_at` | integer | no | | | `customers[].subscriptions` | array of object | no | | | `customers[].subscriptions[].source_id` | string | no | | | `customers[].subscriptions[].app_id` | string or null | no | | | `customers[].subscriptions[].store` | string | yes | | | `customers[].subscriptions[].product_identifier` | string | yes | | | `customers[].subscriptions[].environment` | `production`, `sandbox` | no | | | `customers[].subscriptions[].ownership` | `purchased`, `family_shared` | no | | | `customers[].subscriptions[].starts_at` | integer | yes | | | `customers[].subscriptions[].current_period_starts_at` | integer | yes | | | `customers[].subscriptions[].current_period_ends_at` | integer or null | no | | | `customers[].subscriptions[].status` | `trialing`, `active`, `expired`, `in_grace_period`, `in_billing_retry`, `paused`, `unknown`, `incomplete` | yes | | | `customers[].subscriptions[].auto_renewal_status` | `will_renew`, `will_not_renew`, `will_change_product`, `will_pause`, `requires_price_increase_consent`, `has_already_renewed` | no | | | `customers[].subscriptions[].store_subscription_identifier` | string | yes | | | `customers[].subscriptions[].original_transaction_id` | string or null | no | | | `customers[].subscriptions[].original_transaction_id_confirmed` | boolean | no | | | `customers[].subscriptions[].purchase_token` | string or null | no | | | `customers[].subscriptions[].period_type` | `normal`, `trial`, `intro`, `promotional`, `prepaid` | no | | | `customers[].subscriptions[].country` | string or null | no | | | `customers[].subscriptions[].price` | object or null | no | | | `customers[].subscriptions[].total_revenue_usd` | number or null | no | | | `customers[].subscriptions[].unsubscribe_detected_at` | integer or null | no | | | `customers[].subscriptions[].billing_issues_detected_at` | integer or null | no | | | `customers[].subscriptions[].grace_period_expires_at` | integer or null | no | | | `customers[].subscriptions[].refunded_at` | integer or null | no | | | `customers[].subscriptions[].auto_resume_at` | integer or null | no | | | `customers[].subscriptions[].entitlement_lookup_keys` | array of string | no | | | `customers[].subscriptions[].auto_renew_product_identifier` | string or null | no | | | `customers[].subscriptions[].transactions` | array of object | no | | | `customers[].purchases` | array of object | no | | | `customers[].purchases[].source_id` | string | no | | | `customers[].purchases[].app_id` | string or null | no | | | `customers[].purchases[].store` | string | yes | | | `customers[].purchases[].product_identifier` | string | yes | | | `customers[].purchases[].environment` | `production`, `sandbox` | no | | | `customers[].purchases[].purchased_at` | integer | yes | | | `customers[].purchases[].store_purchase_identifier` | string | yes | | | `customers[].purchases[].status` | `owned`, `refunded` | no | | | `customers[].purchases[].refunded_at` | integer or null | no | | | `customers[].purchases[].consumable` | boolean | no | | | `customers[].purchases[].price` | object or null | no | | | `customers[].purchases[].revenue_usd` | number or null | no | | | `customers[].purchases[].country` | string or null | no | | | `emit_events` | boolean | no | Default false. | | `resolve_store_ids` | boolean | no | Default true: use the app's store credentials to confirm Apple ids and find Google tokens. | **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/import/customers" -H "Authorization: Bearer $SECRET_KEY" \ -H "Content-Type: application/json" -d '{"customers":[{"id":"imported_1","aliases":["$RCAnonymousID:0f1e2d"],"first_seen_at":1735689600000,"attributes":[{"name":"$email","value":"ana@example.com"}],"subscriptions":[{"store":"test_store","app_id":"appvnrm0a5h","product_identifier":"pro_annual","starts_at":1735689600000,"current_period_starts_at":1767225600000,"current_period_ends_at":1798761600000,"status":"active","auto_renewal_status":"will_renew","store_subscription_identifier":"test_1767225600000_imported"}]}]}' ``` **Responses** - **200**: A report per customer. Returns [ImportResult](https://revenuedot.app/docs/api/extensions.md#importresult). - **400**: The request is invalid. Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). Example 200 response: ```json { "object": "import_result", "emit_events": false, "customers": [ { "id": "imported_1", "status": "created", "subscriptions": 1, "purchases": 0, "needs_token_refresh": 0, "notes": [] } ] } ``` ### Keep an app's existing SDK key `POST /v2/projects/{project_id}/import/apps/{app_id}/public_key` · Auth: secret key or dashboard session · RevenueDot extension · Permissions: `project_configuration:apps:read_write` Sets the app's public key to the one your shipped app binaries already send (appl_..., goog_...), so old app versions work against RevenueDot. The prefix must match the app's store. **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | | `app_id` | string | yes | App id (app...). | **Request body** (`application/json`) | Field | Type | Required | Description | |---|---|---|---| | `public_key` | string | yes | | **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/import/apps/$APP_ID/public_key" -H "Authorization: Bearer $SECRET_KEY" \ -H "Content-Type: application/json" -d '{"public_key":"appl_AbCdEfGhIjKlMnOpQrStUvWxYz"}' ``` **Responses** - **200**: The key. Returns [PublicApiKey](https://revenuedot.app/docs/api/extensions.md#publicapikey). - **400**: The request is invalid. Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). - **409**: It already exists, or it conflicts with another object. Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). ### What still needs attention after an import `GET /v2/projects/{project_id}/import/status` · Auth: secret key or dashboard session · RevenueDot extension · Permissions: `customer_information:customers:read` **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | Project id (proj...). | **Example request** ```bash curl -s "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/import/status" -H "Authorization: Bearer $SECRET_KEY" ``` **Responses** - **200**: Counts. Returns [ImportStatus](https://revenuedot.app/docs/api/extensions.md#importstatus). - **401**: No API key, or an unknown one. Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). - **403**: The key lacks a permission, or a public key was used. Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). - **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](https://revenuedot.app/docs/api/extensions.md#v2error). Example 200 response: ```json { "object": "import_status", "customers": 13, "subscriptions": 10, "needs_token_refresh": 0, "needs_token_refresh_by_app": {} } ``` ## OAuth for MCP clients OAuth 2.1 with PKCE so MCP clients can connect to one project without copying a key. ### OAuth authorization server metadata `GET /.well-known/oauth-authorization-server` · Auth: none · RevenueDot extension RFC 8414 metadata for MCP clients (Claude, ChatGPT, Cursor ...). Endpoints are built from this server's public origin. **Example request** ```bash curl -s "$REVENUEDOT_URL/.well-known/oauth-authorization-server" ``` **Responses** - **200**: Metadata. Example 200 response: ```json { "issuer": "https://revenuedot.example.com", "authorization_endpoint": "https://revenuedot.example.com/oauth/authorize", "token_endpoint": "https://revenuedot.example.com/oauth/token", "registration_endpoint": "https://revenuedot.example.com/oauth/register", "scopes_supported": [ "project:read", "project:write" ], "response_types_supported": [ "code" ], "response_modes_supported": [ "query" ], "grant_types_supported": [ "authorization_code" ], "token_endpoint_auth_methods_supported": [ "none" ], "code_challenge_methods_supported": [ "S256" ], "service_documentation": "https://revenuedot.app/docs/mcp" } ``` ### Register an OAuth client `POST /oauth/register` · Auth: none · RevenueDot extension Dynamic client registration (RFC 7591), public clients only. Redirect URIs must be https, http on localhost, or an app scheme such as cursor://. **Request body** (`application/json`) | Field | Type | Required | Description | |---|---|---|---| | `redirect_uris` | array of string | yes | | | `client_name` | string | no | | | `grant_types` | array of string | no | | **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/oauth/register" \ -H "Content-Type: application/json" -d '{"client_name":"Claude","redirect_uris":["https://claude.ai/api/mcp/auth_callback"]}' ``` **Responses** - **201**: The client. - **400**: Invalid metadata or redirect URI. ### Consent screen `GET /oauth/authorize` · Auth: none · RevenueDot extension An HTML page. The user signs in to the dashboard (the session cookie is reused), picks one project and read or read-write access. PKCE with S256 is required. **Query parameters** | Name | Type | Required | Description | |---|---|---|---| | `response_type` | string | no | | | `client_id` | string | no | | | `redirect_uri` | string | no | | | `state` | string | no | | | `scope` | string | no | | | `code_challenge` | string | no | | | `code_challenge_method` | string | no | | | `resource` | string | no | | **Example request** ```bash curl -s "$REVENUEDOT_URL/oauth/authorize" ``` **Responses** - **200**: The consent page. - **302**: Back to the client with an error. - **400**: Unknown client or redirect URI. ### Submit the consent decision `POST /oauth/authorize` · Auth: dashboard session · RevenueDot extension The consent form posts here. On allow, redirects to the client's redirect URI with a one-time `code` (valid 10 minutes). **Request body** (`application/x-www-form-urlencoded`) | Field | Type | Required | Description | |---|---|---|---| | `decision` | `allow`, `deny` | no | | | `project_id` | string | no | | | `access` | `project:read`, `project:write` | no | | | `csrf` | string | no | | **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/oauth/authorize" ``` **Responses** - **302**: Redirect with `code` and `state`, or with `error`. - **403**: The form expired. ### Exchange a code for an access token `POST /oauth/token` · Auth: none · RevenueDot extension authorization_code grant with PKCE. The access token is a secret API key (sk_...) bound to the chosen project with the approved permissions. It does not expire; revoke it on the project's API keys page. **Request body** (`application/x-www-form-urlencoded`) | Field | Type | Required | Description | |---|---|---|---| | `grant_type` | `"authorization_code"` | yes | | | `code` | string | yes | | | `code_verifier` | string | yes | | | `client_id` | string | no | | | `redirect_uri` | string | no | | **Example request** ```bash curl -s -X POST "$REVENUEDOT_URL/oauth/token" ``` **Responses** - **200**: The token. - **400**: Invalid grant or request. ## Objects The shapes the operations above send and return. ### ActiveEntitlement | Field | Type | Required | Description | |---|---|---|---| | `object` | `"customer.active_entitlement"` | yes | | | `entitlement_id` | string | yes | Entitlement id (entl...), not the lookup key. | | `expires_at` | integer or null | yes | When access ends. Epoch milliseconds, or null. | ### ApiKey | Field | Type | Required | Description | |---|---|---|---| | `object` | `"api_key"` | yes | | | `id` | string | yes | | | `name` | string | yes | | | `prefix` | string | yes | First 7 characters of the key. | | `permissions` | array of string | yes | Scopes; `*` is every scope. | | `created_at` | integer | yes | Creation time. Epoch milliseconds. | | `last_used_at` | integer or null | yes | Last use, updated at most once a minute. Epoch milliseconds, or null. | | `key` | string | no | The secret key (sk_...). Only in the answer that creates it. | ### App Only the object for the app's own `type` is present. Store secrets are never returned. | Field | Type | Required | Description | |---|---|---|---| | `object` | `"app"` | yes | | | `id` | string | yes | App id (app...). | | `name` | string | yes | | | `created_at` | integer | yes | Creation time. Epoch milliseconds. | | `type` | `amazon`, `app_store`, `mac_app_store`, `play_store`, `stripe`, `rc_billing`, `roku`, `paddle`, `test_store` | yes | | | `project_id` | string | yes | | | `custom_url_scheme` | string | no | Derived from the public key. | | `app_store` | object | no | | | `app_store.bundle_id` | string | no | | | `app_store.app_store_connect_api_key_configured` | boolean | no | | | `app_store.subscription_key_configured` | boolean | no | True when the in-app purchase key (.p8, key id, issuer id) is set. | | `app_store.app_store_connect_vendor_number` | string or null | no | | | `mac_app_store` | object | no | | | `mac_app_store.bundle_id` | string | no | | | `play_store` | object | no | | | `play_store.package_name` | string | no | | | `play_store.play_service_account_credentials_configured` | boolean | no | | | `amazon` | object | no | | | `amazon.package_name` | string | no | | | `stripe` | object | no | | | `stripe.stripe_account_id` | string or null | no | | | `rc_billing` | object | no | | | `rc_billing.stripe_account_id` | string or null | no | | | `rc_billing.seller_company_name` | string | no | | | `rc_billing.app_name` | string | no | | | `rc_billing.support_email` | string or null | no | | | `rc_billing.default_currency` | string | no | | | `roku` | object | no | | | `roku.roku_channel_id` | string or null | no | | | `roku.roku_channel_name` | string or null | no | | | `paddle` | object | no | | | `paddle.paddle_is_sandbox` | boolean | no | | | `paddle.paddle_api_key` | null | no | | ### Collaborator | Field | Type | Required | Description | |---|---|---|---| | `object` | `"collaborator"` | yes | | | `id` | string | yes | | | `name` | string or null | no | | | `email` | string | yes | | | `role` | `admin`, `developer`, `read_only` | yes | RevenueCat's role names. `read_only` is the dashboard's Viewer role. | | `accepted_at` | integer | no | When the user joined. Epoch milliseconds. | | `has_mfa` | boolean | no | Always false. | ### CredentialsCheck | Field | Type | Required | Description | |---|---|---|---| | `object` | `"credentials_check"` | yes | | | `app_id` | string | yes | | | `store` | string | yes | | | `status` | `valid`, `invalid`, `unreachable` | yes | | | `valid` | boolean | yes | | | `message` | string | yes | What to do next, in plain words. | | `checked_at` | integer | yes | Checked at. Epoch milliseconds. | | `key_id` | string | no | | | `client_email` | string or null | no | | ### Customer `active_entitlements` and `experiment` are present on single-customer answers; `attributes` only with `expand=attributes`. | Field | Type | Required | Description | |---|---|---|---| | `object` | `"customer"` | yes | | | `id` | string | yes | The customer's original app user id. | | `project_id` | string | yes | | | `first_seen_at` | integer | yes | First seen. Epoch milliseconds. | | `last_seen_at` | integer or null | yes | Last seen. Epoch milliseconds, or null. | | `last_seen_app_version` | string or null | no | | | `last_seen_country` | string or null | no | | | `last_seen_platform` | string or null | no | | | `last_seen_platform_version` | null | no | | | `active_entitlements` | object | no | | | `active_entitlements.object` | `"list"` | yes | | | `active_entitlements.items` | array of ActiveEntitlement | yes | | | `active_entitlements.next_page` | string or null | yes | Path of the next page, or null on the last page. | | `active_entitlements.url` | string | yes | Path of this list. | | `experiment` | null | no | | | `attributes` | object | no | | | `attributes.object` | `"list"` | yes | | | `attributes.items` | array of CustomerAttribute | yes | | | `attributes.next_page` | string or null | yes | Path of the next page, or null on the last page. | | `attributes.url` | string | yes | Path of this list. | ### CustomerAttribute | Field | Type | Required | Description | |---|---|---|---| | `object` | `"customer.attribute"` | yes | | | `name` | string | yes | | | `value` | string | yes | | | `updated_at` | integer | yes | Last update. Epoch milliseconds. | ### CustomerSummary | Field | Type | Required | Description | |---|---|---|---| | `object` | `"customer_summary"` | yes | | | `id` | string | yes | The id you asked for. | | `original_app_user_id` | string | yes | | | `aliases` | array of string | no | | | `total_revenue_in_usd` | number | no | | | `sandbox_revenue_in_usd` | number | no | | | `country` | string or null | no | | | `platform` | string or null | no | | | `stores` | array of string | no | | | `offering_override` | string or null | no | | | `active_entitlements` | array of object | no | | | `granted_entitlements` | array of object | no | | | `subscriptions` | array of object | no | | | `purchases` | array of object | no | | ### Deleted | Field | Type | Required | Description | |---|---|---|---| | `object` | string | yes | The deleted object's type. | | `id` | string | yes | | | `deleted_at` | integer | yes | When it was deleted. Epoch milliseconds. | ### Entitlement | Field | Type | Required | Description | |---|---|---|---| | `object` | `"entitlement"` | yes | | | `id` | string | yes | Entitlement id (entl...). | | `project_id` | string | yes | | | `lookup_key` | string | yes | What apps check, for example `pro`. | | `display_name` | string | yes | | | `created_at` | integer | yes | Creation time. Epoch milliseconds. | | `state` | `active`, `inactive` | yes | | | `products` | object | no | | | `products.object` | `"list"` | yes | | | `products.items` | array of Product | yes | | | `products.next_page` | string or null | yes | Path of the next page, or null on the last page. | | `products.url` | string | yes | Path of this list. | ### Event | Field | Type | Required | Description | |---|---|---|---| | `object` | `"event"` | yes | | | `id` | string | yes | | | `type` | string | yes | | | `environment` | `production`, `sandbox` | yes | | | `app_id` | string or null | no | | | `customer_id` | string or null | no | Original app user id. | | `app_user_id` | string or null | no | | | `occurred_at` | integer | yes | When it happened. Epoch milliseconds. | | `created_at` | integer | no | Recorded. Epoch milliseconds. | | `body` | object | yes | The webhook `event` object, exactly as webhooks receive it. | ### ImportResult | Field | Type | Required | Description | |---|---|---|---| | `object` | `"import_result"` | yes | | | `emit_events` | boolean | yes | | | `customers` | array of object | yes | | | `customers[].id` | string | no | | | `customers[].status` | `created`, `updated`, `merged` | no | | | `customers[].subscriptions` | integer | no | | | `customers[].purchases` | integer | no | | | `customers[].needs_token_refresh` | integer | no | | | `customers[].notes` | array of string | no | | ### ImportStatus | Field | Type | Required | Description | |---|---|---|---| | `object` | `"import_status"` | yes | | | `customers` | integer | yes | | | `subscriptions` | integer | yes | | | `needs_token_refresh` | integer | yes | Google Play subscriptions still waiting for their purchase token. | | `needs_token_refresh_by_app` | object | yes | | ### IndicativePrice | Field | Type | Required | Description | |---|---|---|---| | `object` | `"indicative_price"` | yes | | | `currency` | string | yes | ISO 4217 code. | | `country` | null | yes | | | `amount_micros` | integer | yes | Price in micros: 9.99 is 9990000. | ### Invite | Field | Type | Required | Description | |---|---|---|---| | `object` | `"invite"` | yes | | | `id` | string | yes | inv_... | | `email` | string | yes | The invited address, lowercased. | | `role` | `admin`, `developer`, `viewer` | yes | The role the person gets when they accept. | | `status` | `pending`, `expired`, `accepted`, `revoked` | yes | Lists only show `pending` and `expired`. An expired invite can be resent. | | `invited_by` | string or null | no | User id of the admin who last sent it. | | `created_at` | integer | no | When it was created. Epoch milliseconds. | | `last_sent_at` | integer | no | When the last email went out. Epoch milliseconds. | | `expires_at` | integer | yes | When the link stops working: 7 days after it was last sent. Epoch milliseconds. | ### MassExtension | Field | Type | Required | Description | |---|---|---|---| | `object` | `"subscription_mass_extension"` | yes | | | `id` | string | yes | Request id. | | `app_id` | string | yes | | | `product_id` | string | yes | | | `environment` | `production`, `sandbox` | yes | | | `extend_by_days` | integer | no | | | `extend_reason_code` | string | no | | | `storefront_country_codes` | array of string | no | | | `complete` | boolean | yes | | | `completed_at` | integer or null | no | | | `succeeded_count` | integer or null | no | | | `failed_count` | integer or null | no | | | `requested_at` | integer | no | | ### MetricHistory | Field | Type | Required | Description | |---|---|---|---| | `object` | `"metric_history"` | yes | | | `id` | string | yes | | | `currency` | `"USD"` | no | | | `days` | integer | yes | | | `environment` | `production`, `sandbox` | yes | | | `resolution` | `"day"` | no | | | `value` | number | no | | | `previous_value` | number or null | no | | | `values` | array of object | yes | | | `values[].date` | string | no | YYYY-MM-DD | | `values[].value` | number | no | | | `last_updated_at` | integer | no | Computed at. Epoch milliseconds. | ### MonetaryAmount | Field | Type | Required | Description | |---|---|---|---| | `currency` | string | yes | ISO 4217 currency code. | | `gross` | number | yes | Gross amount. | | `commission` | number | yes | Estimated store commission. | | `tax` | number | yes | Tax. Always 0 today. | | `proceeds` | number | yes | Gross minus commission. | ### Price | Field | Type | Required | Description | |---|---|---|---| | `amount` | number | yes | Price in the purchase currency. | | `currency` | string | yes | ISO 4217 currency code. | ### Product | Field | Type | Required | Description | |---|---|---|---| | `object` | `"product"` | yes | | | `id` | string | yes | Product id (prod...). | | `store_identifier` | string | yes | The store's product id. Google Play subscriptions use `subscriptionId:basePlanId`. | | `type` | `subscription`, `one_time`, `consumable`, `non_consumable`, `non_renewing_subscription` | yes | | | `state` | `active`, `inactive` | yes | | | `subscription` | object | no | | | `subscription.duration` | string or null | no | ISO 8601 period such as P1M. | | `subscription.grace_period_duration` | null | no | | | `subscription.trial_duration` | null | no | | | `one_time` | object | no | | | `one_time.is_consumable` | boolean or null | no | | | `created_at` | integer | yes | Creation time. Epoch milliseconds. | | `app_id` | string | yes | | | `display_name` | string or null | yes | | | `app` | App | no | Only the object for the app's own `type` is present. Store secrets are never returned. | | `indicative_price` | IndicativePrice or null | no | With `expand=indicative_price`: the Test Store price, or null. | ### Project | Field | Type | Required | Description | |---|---|---|---| | `object` | `"project"` | yes | | | `id` | string | yes | Project id (proj...). | | `name` | string | yes | | | `created_at` | integer | yes | Creation time. Epoch milliseconds. | | `icon_url` | string or null | no | Always null. | | `icon_url_large` | string or null | no | Always null. | ### ProjectSettings | Field | Type | Required | Description | |---|---|---|---| | `object` | `"project"` | yes | | | `id` | string | yes | Project id (proj...). | | `name` | string | yes | | | `created_at` | integer | yes | Creation time. Epoch milliseconds. | | `icon_url` | string or null | no | Always null. | | `icon_url_large` | string or null | no | Always null. | | `transfer_behavior` | `transfer`, `transfer_if_no_active`, `keep`, `share` | yes | What happens when a purchase already owned by another customer is restored. Default transfer. | | `sandbox_transfer_behavior` | `transfer`, `transfer_if_no_active`, `keep`, `share`, null | yes | Override for sandbox purchases; null uses transfer_behavior. | ### PublicApiKey | Field | Type | Required | Description | |---|---|---|---| | `object` | `"public_api_key"` | yes | | | `id` | string | yes | | | `key` | string | yes | The key the SDK sends (appl_, goog_, test_ ...). | | `environment` | `production`, `sandbox` | yes | | | `app_id` | string | yes | | | `created_at` | integer | yes | Creation time. Epoch milliseconds. | ### Purchase | Field | Type | Required | Description | |---|---|---|---| | `object` | `"purchase"` | yes | | | `id` | string | yes | | | `customer_id` | string | yes | | | `original_customer_id` | string | no | | | `product_id` | string | yes | | | `purchased_at` | integer | yes | Purchase time. Epoch milliseconds. | | `revenue_in_usd` | MonetaryAmount | no | | | `quantity` | integer | no | | | `status` | `owned`, `refunded` | yes | | | `presented_offering_id` | string or null | no | Offering the purchase was made from (its id, or the identifier the SDK sent when no such offering exists). | | `entitlements` | object | no | | | `entitlements.object` | `"list"` | yes | | | `entitlements.items` | array of Entitlement | yes | | | `entitlements.next_page` | string or null | yes | Path of the next page, or null on the last page. | | `entitlements.url` | string | yes | Path of this list. | | `environment` | `production`, `sandbox` | yes | | | `store` | string | yes | | | `store_purchase_identifier` | string | no | | | `ownership` | `purchased` | no | | | `country` | string | no | | ### SetupHealth | Field | Type | Required | Description | |---|---|---|---| | `object` | `"setup_health"` | yes | | | `project_id` | string | yes | | | `checked_at` | integer | yes | Computed at. Epoch milliseconds. | | `apps` | array of object | yes | | | `apps[].id` | string | no | | | `apps[].name` | string | no | | | `apps[].type` | string | no | | | `apps[].notification_url` | string or null | no | Where the store must send notifications. | | `apps[].last_notification_at` | integer or null | no | Last notification processed for a known purchase (or the store's test). Epoch milliseconds, or null. | | `apps[].last_notification_received_at` | integer or null | no | Last notification received at all. Epoch milliseconds, or null. | | `apps[].last_notification_error` | object or null | no | | | `apps[].last_notification_error.at` | integer | no | | | `apps[].last_notification_error.type` | string or null | no | | | `apps[].last_notification_error.message` | string | no | | | `apps[].notification_status` | `ready`, `failing`, `received`, `waiting` | no | | | `apps[].credentials_configured` | boolean | no | | | `webhooks` | object | yes | | | `webhooks.total` | integer | no | | | `webhooks.attempted_24h` | integer | no | | | `webhooks.delivered_24h` | integer | no | | | `webhooks.failed_24h` | integer | no | | | `webhooks.pending` | integer | no | | | `webhooks.delivered_percent_24h` | number or null | no | | | `webhooks.failing` | array of object | no | | | `webhooks.failing[].id` | string | no | | | `webhooks.failing[].name` | string | no | | | `webhooks.failing[].url` | string | no | | | `webhooks.failing[].last_status` | integer or null | no | | | `webhooks.failing[].last_error` | string or null | no | | | `webhooks.failing[].last_attempt_at` | integer | no | | | `webhooks.failing[].delivery_status` | string | no | | | `sdk_versions` | array of object | yes | | ### StoreSettings | Field | Type | Required | Description | |---|---|---|---| | `object` | `"app_store_settings"` | yes | | | `app_id` | string | yes | | | `type` | string | yes | | | `api_origin` | string | yes | This server as the outside world reaches it: the SDK's proxy URL. | | `notification_url` | string or null | no | App Store or Google Play notification URL for this app. | | `notification_forward_url` | string or null | no | Where notifications are copied during a dual run. | | `last_notification_at` | integer or null | no | Last notification processed for a known purchase. Epoch milliseconds, or null. | | `last_notification_error` | string or null | no | | | `last_notification_received_at` | integer or null | no | Last notification received. Epoch milliseconds, or null. | | `notification_status` | `ready`, `failing`, `received`, `waiting` | yes | | | `last_forward` | object or null | no | | | `last_forward.status` | integer | no | HTTP status of the forward; 0 means no answer. | | `last_forward.at` | integer | no | | | `track_new_purchases` | boolean | no | Apply notifications about purchases this server has never seen. | | `allow_unsigned_receipts` | boolean | no | Accept StoreKit 1 receipts without the in-app purchase key. Development only. | | `credentials` | object | yes | | | `credentials.subscription_key` | object | no | | | `credentials.subscription_key.configured` | boolean | no | | | `credentials.subscription_key.key_id` | string or null | no | | | `credentials.subscription_key.issuer_id` | string or null | no | | | `credentials.app_store_connect_api_key` | object | no | | | `credentials.app_store_connect_api_key.configured` | boolean | no | | | `credentials.app_store_connect_api_key.key_id` | string or null | no | | | `credentials.app_store_connect_api_key.issuer_id` | string or null | no | | | `credentials.app_store_connect_api_key.vendor_number` | string or null | no | | | `credentials.shared_secret` | object | no | | | `credentials.shared_secret.configured` | boolean | no | | | `credentials.play_service_account` | object | no | | | `credentials.play_service_account.configured` | boolean | no | | | `credentials.play_service_account.client_email` | string or null | no | | | `credentials.xcode_certificate` | object | no | | | `credentials.xcode_certificate.configured` | boolean | no | | ### Subscription | Field | Type | Required | Description | |---|---|---|---| | `object` | `"subscription"` | yes | | | `id` | string | yes | Subscription id (sub_...). | | `customer_id` | string | yes | | | `original_customer_id` | string | no | | | `product_id` | string or null | no | Product id (prod...), null for promotional grants. | | `starts_at` | integer | yes | Start of the subscription. Epoch milliseconds. | | `current_period_starts_at` | integer | no | Start of the current period. Epoch milliseconds. | | `current_period_ends_at` | integer or null | no | End of the current period. Epoch milliseconds, or null. | | `ends_at` | integer or null | no | End of access. Epoch milliseconds, or null. | | `gives_access` | boolean | yes | | | `pending_payment` | boolean | no | | | `auto_renewal_status` | `will_renew`, `will_not_renew`, `will_change_product`, `will_pause` | yes | | | `status` | `trialing`, `active`, `in_grace_period`, `in_billing_retry`, `paused`, `expired` | yes | | | `total_revenue_in_usd` | MonetaryAmount | no | | | `presented_offering_id` | string or null | no | Offering the purchase was made from (its id, or the identifier the SDK sent when no such offering exists). | | `entitlements` | object | no | | | `entitlements.object` | `"list"` | yes | | | `entitlements.items` | array of Entitlement | yes | | | `entitlements.next_page` | string or null | yes | Path of the next page, or null on the last page. | | `entitlements.url` | string | yes | Path of this list. | | `environment` | `production`, `sandbox` | yes | | | `store` | string | yes | | | `store_subscription_identifier` | string | no | Latest store transaction id, order id or token. | | `ownership` | `purchased`, `family_shared` | no | | | `country` | string | no | ISO 3166-1 alpha-2, when known. | | `management_url` | null | no | | ### TestPurchase | Field | Type | Required | Description | |---|---|---|---| | `object` | `"test_purchase"` | yes | | | `scenario` | string | yes | | | `store_transaction_id` | string | yes | The Test Store token (test__). | | `event_types` | array of string | yes | Events recorded, in order. | | `customer` | Customer | yes | `active_entitlements` and `experiment` are present on single-customer answers; `attributes` only with `expand=attributes`. | | `subscription` | Subscription or null | no | | | `purchase` | Purchase or null | no | | ### Transaction | Field | Type | Required | Description | |---|---|---|---| | `object` | `"transaction"` | yes | | | `id` | string | yes | | | `customer_id` | string | yes | | | `app_id` | string or null | no | | | `store` | string | yes | | | `store_transaction_id` | string or null | no | | | `product_identifier` | string | yes | | | `kind` | `purchase`, `renewal`, `trial`, `one_time`, `refund`, `refund_reversal` | yes | | | `environment` | `production`, `sandbox` | yes | | | `purchased_at` | integer | yes | When the money moved. Epoch milliseconds. | | `expires_at` | integer or null | no | End of the period. Epoch milliseconds, or null. | | `revenue_in_usd` | number | yes | USD; negative for refunds. | | `price` | Price or null | no | | | `country` | string or null | no | | ### V2Error | Field | Type | Required | Description | |---|---|---|---| | `object` | `"error"` | yes | | | `type` | `parameter_error`, `resource_already_exists`, `resource_missing`, `idempotency_error`, `rate_limit_error`, `authentication_error`, `authorization_error`, `store_error`, `server_error`, `resource_locked_error`, `unprocessable_entity_error`, `invalid_request`, `entity_references_archived_entities` | yes | | | `message` | string | yes | What went wrong. | | `param` | string | no | The request field at fault, when there is one. | | `doc_url` | string | yes | Link to the error's section of the errors page. | | `retryable` | boolean | yes | True when retrying the same request can succeed. | ### WebhookDelivery | Field | Type | Required | Description | |---|---|---|---| | `object` | `"webhook_delivery"` | yes | | | `id` | string | yes | | | `webhook_integration_id` | string | yes | | | `event_id` | string | yes | | | `event_type` | string | yes | | | `status` | `pending`, `delivered`, `failed` | yes | | | `attempts` | integer | yes | | | `next_attempt_at` | integer or null | no | Next retry, when pending. Epoch milliseconds, or null. | | `response_status` | integer or null | no | HTTP status of the last attempt. | | `response_ms` | integer or null | no | Duration of the last attempt. | | `last_error` | string or null | no | | | `created_at` | integer | no | Queued at. Epoch milliseconds. | ### WebhookState | Field | Type | Required | Description | |---|---|---|---| | `object` | `"webhook_state"` | yes | | | `id` | string | yes | Webhook id (wh_...). | | `enabled` | boolean | yes | False while deliveries are paused. | ## Related - [API overview](https://revenuedot.app/docs/api.md) - [Authentication](https://revenuedot.app/docs/api/authentication.md) - [Errors](https://revenuedot.app/docs/api/errors.md) - [OpenAPI document](https://revenuedot.app/docs/api/openapi.yaml) --- # Which webhook events does RevenueDot send? Source: https://revenuedot.app/docs/api/webhook-events.md Description: Every webhook event type RevenueDot sends, when each fires, its fields and a real example payload, plus the signature header and retry rules. RevenueDot POSTs one JSON event per request to each matching webhook: `{ "api_version": "1.0", "event": { ... } }`. The payload matches RevenueCat's webhook format field for field, so existing handlers keep working. Set up, verify and test webhooks with the [webhooks guide](https://revenuedot.app/docs/guides/webhooks.md). - **Headers:** `Content-Type: application/json`, `User-Agent: RevenueDot-Webhooks/1.0`, `X-RevenueCat-Webhook-Signature: t=,v1=`, and your `Authorization` header when you set one. - **Delivery:** only HTTP 200 counts. Anything else, or no answer within 60 seconds, is retried after 5, 10, 20, 40 and 80 minutes, then marked failed. Deliveries can repeat: deduplicate on `event.id`. - **Examples:** recorded from a RevenueDot server on 2026-09-30 with Test Store purchases. Events that the Test Store cannot produce (pause, product change, extension, refund reversal, uncancellation, price consent) show the same builder's output with App Store or Google Play values. | Event | When it is sent | |---|---| | [`INITIAL_PURCHASE`](https://revenuedot.app/docs/api/webhook-events.md#initial_purchase) | The first purchase of a subscription, including a free trial start. | | [`RENEWAL`](https://revenuedot.app/docs/api/webhook-events.md#renewal) | A new paid period: a renewal, a trial converting (`is_trial_conversion: true`), a lapsed customer resubscribing, or a recovered billing issue. | | [`CANCELLATION`](https://revenuedot.app/docs/api/webhook-events.md#cancellation) | Auto-renew was turned off, or the purchase was refunded. Access continues to `expiration_at_ms` unless it was a refund. A refund has `cancel_reason: CUSTOMER_SUPPORT` and a negative price. | | [`UNCANCELLATION`](https://revenuedot.app/docs/api/webhook-events.md#uncancellation) | Auto-renew was turned back on before the subscription expired. | | [`NON_RENEWING_PURCHASE`](https://revenuedot.app/docs/api/webhook-events.md#non_renewing_purchase) | A one-time purchase: consumable, non-consumable or lifetime. | | [`SUBSCRIPTION_PAUSED`](https://revenuedot.app/docs/api/webhook-events.md#subscription_paused) | A Google Play subscription is scheduled to pause. It will not renew at the end of the period. | | [`EXPIRATION`](https://revenuedot.app/docs/api/webhook-events.md#expiration) | Access ended: the period ran out, billing retry gave up or the subscription paused. | | [`BILLING_ISSUE`](https://revenuedot.app/docs/api/webhook-events.md#billing_issue) | A renewal charge failed. The store retries; access may continue in a grace period. | | [`PRODUCT_CHANGE`](https://revenuedot.app/docs/api/webhook-events.md#product_change) | The customer changed product: an upgrade now, or a downgrade or crossgrade scheduled for the next renewal. `product_id` is the old product. | | [`SUBSCRIPTION_EXTENDED`](https://revenuedot.app/docs/api/webhook-events.md#subscription_extended) | The current period got longer without a new payment: an App Store renewal extension or a Google Play deferral. | | [`REFUND_REVERSED`](https://revenuedot.app/docs/api/webhook-events.md#refund_reversed) | A refund was reversed and access is back. | | [`PRICE_INCREASE_CONSENT_REQUIRED`](https://revenuedot.app/docs/api/webhook-events.md#price_increase_consent_required) | The store asks the customer to accept a price increase. | | [`PRICE_INCREASE_CONSENT_APPROVED`](https://revenuedot.app/docs/api/webhook-events.md#price_increase_consent_approved) | The customer accepted the price increase. | | [`TRANSFER`](https://revenuedot.app/docs/api/webhook-events.md#transfer) | A purchase moved to another customer because that customer restored it (transfer behaviour `transfer` or `transfer_if_no_active`). | | [`TEST`](https://revenuedot.app/docs/api/webhook-events.md#test) | Sent by the dashboard's "Send test event" or `POST .../integrations/webhooks/{id}/test`. Shaped like a purchase. | Accepted in a webhook's `event_types` filter but never sent yet: `TEMPORARY_ENTITLEMENT_GRANT`, `VIRTUAL_CURRENCY_TRANSACTION`, `INVOICE_ISSUANCE`, `EXPERIMENT_ENROLLMENT`, `PURCHASE_REDEEMED`, `SUBSCRIBER_ALIAS`. ## INITIAL_PURCHASE The first purchase of a subscription, including a free trial start. | Field | Type | Description | |---|---|---| | `id` | string | Unique event id (upper-case UUID). Deduplicate on it. | | `type` | string | Event type. | | `event_timestamp_ms` | integer | When RevenueDot recorded the event. Epoch milliseconds. | | `app_id` | string | RevenueDot app id. Left out for promotional grants. | | `app_user_id` | string | The app user id the event is about (a non-anonymous alias when there is one). | | `original_app_user_id` | string | The customer's first app user id. | | `aliases` | array of string | Every app user id of the customer. | | `product_id` | string | Store product id. For PRODUCT_CHANGE, the product the customer changed from. | | `period_type` | `NORMAL`, `TRIAL`, `INTRO`, `PROMOTIONAL`, `PREPAID` | | | `purchased_at_ms` | integer | Start of the period. Epoch milliseconds. | | `expiration_at_ms` | integer or null | End of the period, or null for lifetime. Epoch milliseconds. | | `environment` | `PRODUCTION`, `SANDBOX` | | | `entitlement_id` | null | Always null (deprecated in RevenueCat's payload). | | `entitlement_ids` | array of string | Lookup keys of the entitlements the product unlocks, or null. | | `presented_offering_id` | string or null | Offering the purchase was made from, when the SDK sent it. | | `transaction_id` | string or null | Store transaction id of this period. | | `original_transaction_id` | string or null | First transaction id of the subscription. | | `is_family_share` | boolean | | | `country_code` | string or null | ISO 3166-1 alpha-2. | | `currency` | string or null | ISO 4217. | | `price` | number or null | USD. Money moved only on INITIAL_PURCHASE, RENEWAL, NON_RENEWING_PURCHASE, REFUND_REVERSED and refunds (negative); 0 on other events. | | `price_in_purchased_currency` | number or null | Same as price, in `currency`. | | `subscriber_attributes` | object | | | `store` | `APP_STORE`, `MAC_APP_STORE`, `PLAY_STORE`, `AMAZON`, `STRIPE`, `RC_BILLING`, `PROMOTIONAL`, `TEST_STORE`, `PADDLE`, `ROKU`, `EXTERNAL` | | | `takehome_percentage` | number | 1 minus the estimated store commission. | | `tax_percentage` | number | Always 0 today. | | `commission_percentage` | number | Estimated store commission (0.3 for App Store and Google Play, 0 for Test Store). | | `offer_code` | null | | Example: ```json { "api_version": "1.0", "event": { "id": "66339910-3BFF-49F4-B873-D1283D673DE2", "type": "INITIAL_PURCHASE", "event_timestamp_ms": 1790800914034, "app_id": "appvnrm0a5h", "app_user_id": "user_1", "original_app_user_id": "user_1", "aliases": [ "user_1" ], "product_id": "pro_monthly", "period_type": "NORMAL", "purchased_at_ms": 1790800914000, "expiration_at_ms": 1793392914000, "environment": "SANDBOX", "entitlement_id": null, "entitlement_ids": [ "pro" ], "presented_offering_id": "default", "transaction_id": "test_1790800914000_quickstart", "original_transaction_id": "test_1790800914000_quickstart", "is_family_share": false, "country_code": null, "currency": "USD", "price": 9.99, "price_in_purchased_currency": 9.99, "subscriber_attributes": {}, "store": "TEST_STORE", "takehome_percentage": 1, "tax_percentage": 0, "commission_percentage": 0, "offer_code": null } } ``` ## RENEWAL A new paid period: a renewal, a trial converting (`is_trial_conversion: true`), a lapsed customer resubscribing, or a recovered billing issue. | Field | Type | Description | |---|---|---| | `id` | string | Unique event id (upper-case UUID). Deduplicate on it. | | `type` | string | Event type. | | `event_timestamp_ms` | integer | When RevenueDot recorded the event. Epoch milliseconds. | | `app_id` | string | RevenueDot app id. Left out for promotional grants. | | `app_user_id` | string | The app user id the event is about (a non-anonymous alias when there is one). | | `original_app_user_id` | string | The customer's first app user id. | | `aliases` | array of string | Every app user id of the customer. | | `product_id` | string | Store product id. For PRODUCT_CHANGE, the product the customer changed from. | | `period_type` | `NORMAL`, `TRIAL`, `INTRO`, `PROMOTIONAL`, `PREPAID` | | | `purchased_at_ms` | integer | Start of the period. Epoch milliseconds. | | `expiration_at_ms` | integer or null | End of the period, or null for lifetime. Epoch milliseconds. | | `environment` | `PRODUCTION`, `SANDBOX` | | | `entitlement_id` | null | Always null (deprecated in RevenueCat's payload). | | `entitlement_ids` | array of string | Lookup keys of the entitlements the product unlocks, or null. | | `presented_offering_id` | string or null | Offering the purchase was made from, when the SDK sent it. | | `transaction_id` | string or null | Store transaction id of this period. | | `original_transaction_id` | string or null | First transaction id of the subscription. | | `is_family_share` | boolean | | | `country_code` | string or null | ISO 3166-1 alpha-2. | | `currency` | string or null | ISO 4217. | | `price` | number or null | USD. Money moved only on INITIAL_PURCHASE, RENEWAL, NON_RENEWING_PURCHASE, REFUND_REVERSED and refunds (negative); 0 on other events. | | `price_in_purchased_currency` | number or null | Same as price, in `currency`. | | `subscriber_attributes` | object | | | `store` | `APP_STORE`, `MAC_APP_STORE`, `PLAY_STORE`, `AMAZON`, `STRIPE`, `RC_BILLING`, `PROMOTIONAL`, `TEST_STORE`, `PADDLE`, `ROKU`, `EXTERNAL` | | | `takehome_percentage` | number | 1 minus the estimated store commission. | | `tax_percentage` | number | Always 0 today. | | `commission_percentage` | number | Estimated store commission (0.3 for App Store and Google Play, 0 for Test Store). | | `offer_code` | null | | | `is_trial_conversion` | boolean | True for the first paid period after a free trial. | Example: ```json { "api_version": "1.0", "event": { "id": "96E753E0-4DEC-4F9B-ABC5-55FBC363769A", "type": "RENEWAL", "event_timestamp_ms": 1790800923895, "app_id": "appvnrm0a5h", "app_user_id": "user_trial_conversion", "original_app_user_id": "user_trial_conversion", "aliases": [ "user_trial_conversion" ], "product_id": "pro_monthly", "period_type": "NORMAL", "purchased_at_ms": 1790800923895, "expiration_at_ms": 1793392923895, "environment": "SANDBOX", "entitlement_id": null, "entitlement_ids": [ "pro" ], "presented_offering_id": null, "transaction_id": "test_1790800923895_c8ba6a2d-5f87-4e08-bb4b-f880624bcd6c..1", "original_transaction_id": "test_1790800923895_c8ba6a2d-5f87-4e08-bb4b-f880624bcd6c", "is_family_share": false, "country_code": null, "currency": "USD", "price": 9.99, "price_in_purchased_currency": 9.99, "subscriber_attributes": {}, "store": "TEST_STORE", "takehome_percentage": 1, "tax_percentage": 0, "commission_percentage": 0, "offer_code": null, "is_trial_conversion": true } } ``` ## CANCELLATION Auto-renew was turned off, or the purchase was refunded. Access continues to `expiration_at_ms` unless it was a refund. A refund has `cancel_reason: CUSTOMER_SUPPORT` and a negative price. | Field | Type | Description | |---|---|---| | `id` | string | Unique event id (upper-case UUID). Deduplicate on it. | | `type` | string | Event type. | | `event_timestamp_ms` | integer | When RevenueDot recorded the event. Epoch milliseconds. | | `app_id` | string | RevenueDot app id. Left out for promotional grants. | | `app_user_id` | string | The app user id the event is about (a non-anonymous alias when there is one). | | `original_app_user_id` | string | The customer's first app user id. | | `aliases` | array of string | Every app user id of the customer. | | `product_id` | string | Store product id. For PRODUCT_CHANGE, the product the customer changed from. | | `period_type` | `NORMAL`, `TRIAL`, `INTRO`, `PROMOTIONAL`, `PREPAID` | | | `purchased_at_ms` | integer | Start of the period. Epoch milliseconds. | | `expiration_at_ms` | integer or null | End of the period, or null for lifetime. Epoch milliseconds. | | `environment` | `PRODUCTION`, `SANDBOX` | | | `entitlement_id` | null | Always null (deprecated in RevenueCat's payload). | | `entitlement_ids` | array of string | Lookup keys of the entitlements the product unlocks, or null. | | `presented_offering_id` | string or null | Offering the purchase was made from, when the SDK sent it. | | `transaction_id` | string or null | Store transaction id of this period. | | `original_transaction_id` | string or null | First transaction id of the subscription. | | `is_family_share` | boolean | | | `country_code` | string or null | ISO 3166-1 alpha-2. | | `currency` | string or null | ISO 4217. | | `price` | number or null | USD. Money moved only on INITIAL_PURCHASE, RENEWAL, NON_RENEWING_PURCHASE, REFUND_REVERSED and refunds (negative); 0 on other events. | | `price_in_purchased_currency` | number or null | Same as price, in `currency`. | | `subscriber_attributes` | object | | | `store` | `APP_STORE`, `MAC_APP_STORE`, `PLAY_STORE`, `AMAZON`, `STRIPE`, `RC_BILLING`, `PROMOTIONAL`, `TEST_STORE`, `PADDLE`, `ROKU`, `EXTERNAL` | | | `takehome_percentage` | number | 1 minus the estimated store commission. | | `tax_percentage` | number | Always 0 today. | | `commission_percentage` | number | Estimated store commission (0.3 for App Store and Google Play, 0 for Test Store). | | `offer_code` | null | | | `cancel_reason` | `UNSUBSCRIBE`, `BILLING_ERROR`, `DEVELOPER_INITIATED`, `PRICE_INCREASE`, `CUSTOMER_SUPPORT`, `UNKNOWN` | | Example (auto-renew turned off): ```json { "api_version": "1.0", "event": { "id": "5A47B64A-DBC9-48CF-B0BE-F15041559619", "type": "CANCELLATION", "event_timestamp_ms": 1790800924019, "app_id": "appvnrm0a5h", "app_user_id": "user_cancel", "original_app_user_id": "user_cancel", "aliases": [ "user_cancel" ], "product_id": "pro_monthly", "period_type": "NORMAL", "purchased_at_ms": 1790800924019, "expiration_at_ms": 1793392924019, "environment": "SANDBOX", "entitlement_id": null, "entitlement_ids": [ "pro" ], "presented_offering_id": null, "transaction_id": "test_1790800924019_bd497110-3c45-48fb-a928-437d22de3e2b", "original_transaction_id": "test_1790800924019_bd497110-3c45-48fb-a928-437d22de3e2b", "is_family_share": false, "country_code": null, "currency": "USD", "price": 0, "price_in_purchased_currency": 0, "subscriber_attributes": {}, "store": "TEST_STORE", "takehome_percentage": 1, "tax_percentage": 0, "commission_percentage": 0, "offer_code": null, "cancel_reason": "UNSUBSCRIBE" } } ``` Example (refund): ```json { "api_version": "1.0", "event": { "id": "BF6C2BFB-91A8-4AD8-B3BB-5BE5E708B3DF", "type": "CANCELLATION", "event_timestamp_ms": 1790800924088, "app_id": "appvnrm0a5h", "app_user_id": "user_refund", "original_app_user_id": "user_refund", "aliases": [ "user_refund" ], "product_id": "pro_monthly", "period_type": "NORMAL", "purchased_at_ms": 1790800924088, "expiration_at_ms": 1793392924088, "environment": "SANDBOX", "entitlement_id": null, "entitlement_ids": [ "pro" ], "presented_offering_id": null, "transaction_id": "test_1790800924088_d06c5cf7-096e-41e7-8d2c-d91e5ac57fb1", "original_transaction_id": "test_1790800924088_d06c5cf7-096e-41e7-8d2c-d91e5ac57fb1", "is_family_share": false, "country_code": null, "currency": "USD", "price": -9.99, "price_in_purchased_currency": -9.99, "subscriber_attributes": {}, "store": "TEST_STORE", "takehome_percentage": 1, "tax_percentage": 0, "commission_percentage": 0, "offer_code": null, "cancel_reason": "CUSTOMER_SUPPORT" } } ``` ## UNCANCELLATION Auto-renew was turned back on before the subscription expired. | Field | Type | Description | |---|---|---| | `id` | string | Unique event id (upper-case UUID). Deduplicate on it. | | `type` | string | Event type. | | `event_timestamp_ms` | integer | When RevenueDot recorded the event. Epoch milliseconds. | | `app_id` | string | RevenueDot app id. Left out for promotional grants. | | `app_user_id` | string | The app user id the event is about (a non-anonymous alias when there is one). | | `original_app_user_id` | string | The customer's first app user id. | | `aliases` | array of string | Every app user id of the customer. | | `product_id` | string | Store product id. For PRODUCT_CHANGE, the product the customer changed from. | | `period_type` | `NORMAL`, `TRIAL`, `INTRO`, `PROMOTIONAL`, `PREPAID` | | | `purchased_at_ms` | integer | Start of the period. Epoch milliseconds. | | `expiration_at_ms` | integer or null | End of the period, or null for lifetime. Epoch milliseconds. | | `environment` | `PRODUCTION`, `SANDBOX` | | | `entitlement_id` | null | Always null (deprecated in RevenueCat's payload). | | `entitlement_ids` | array of string | Lookup keys of the entitlements the product unlocks, or null. | | `presented_offering_id` | string or null | Offering the purchase was made from, when the SDK sent it. | | `transaction_id` | string or null | Store transaction id of this period. | | `original_transaction_id` | string or null | First transaction id of the subscription. | | `is_family_share` | boolean | | | `country_code` | string or null | ISO 3166-1 alpha-2. | | `currency` | string or null | ISO 4217. | | `price` | number or null | USD. Money moved only on INITIAL_PURCHASE, RENEWAL, NON_RENEWING_PURCHASE, REFUND_REVERSED and refunds (negative); 0 on other events. | | `price_in_purchased_currency` | number or null | Same as price, in `currency`. | | `subscriber_attributes` | object | | | `store` | `APP_STORE`, `MAC_APP_STORE`, `PLAY_STORE`, `AMAZON`, `STRIPE`, `RC_BILLING`, `PROMOTIONAL`, `TEST_STORE`, `PADDLE`, `ROKU`, `EXTERNAL` | | | `takehome_percentage` | number | 1 minus the estimated store commission. | | `tax_percentage` | number | Always 0 today. | | `commission_percentage` | number | Estimated store commission (0.3 for App Store and Google Play, 0 for Test Store). | | `offer_code` | null | | Example: ```json { "api_version": "1.0", "event": { "id": "0C1F6F2E-5B7A-4E8B-9D1C-3A2B1C0D9E01", "type": "UNCANCELLATION", "event_timestamp_ms": 1790800914034, "app_id": "appugfw01uy", "app_user_id": "user_1", "original_app_user_id": "user_1", "aliases": [ "user_1" ], "product_id": "pro_monthly", "period_type": "NORMAL", "purchased_at_ms": 1790800914000, "expiration_at_ms": 1793392914000, "environment": "PRODUCTION", "entitlement_id": null, "entitlement_ids": [ "pro" ], "presented_offering_id": null, "transaction_id": "2000000912345679", "original_transaction_id": "2000000912345678", "is_family_share": false, "country_code": "US", "currency": "USD", "price": 0, "price_in_purchased_currency": 0, "subscriber_attributes": {}, "store": "APP_STORE", "takehome_percentage": 0.7, "tax_percentage": 0, "commission_percentage": 0.3, "offer_code": null } } ``` ## NON_RENEWING_PURCHASE A one-time purchase: consumable, non-consumable or lifetime. | Field | Type | Description | |---|---|---| | `id` | string | Unique event id (upper-case UUID). Deduplicate on it. | | `type` | string | Event type. | | `event_timestamp_ms` | integer | When RevenueDot recorded the event. Epoch milliseconds. | | `app_id` | string | RevenueDot app id. Left out for promotional grants. | | `app_user_id` | string | The app user id the event is about (a non-anonymous alias when there is one). | | `original_app_user_id` | string | The customer's first app user id. | | `aliases` | array of string | Every app user id of the customer. | | `product_id` | string | Store product id. For PRODUCT_CHANGE, the product the customer changed from. | | `period_type` | `NORMAL`, `TRIAL`, `INTRO`, `PROMOTIONAL`, `PREPAID` | | | `purchased_at_ms` | integer | Start of the period. Epoch milliseconds. | | `expiration_at_ms` | integer or null | End of the period, or null for lifetime. Epoch milliseconds. | | `environment` | `PRODUCTION`, `SANDBOX` | | | `entitlement_id` | null | Always null (deprecated in RevenueCat's payload). | | `entitlement_ids` | array of string | Lookup keys of the entitlements the product unlocks, or null. | | `presented_offering_id` | string or null | Offering the purchase was made from, when the SDK sent it. | | `transaction_id` | string or null | Store transaction id of this period. | | `original_transaction_id` | string or null | First transaction id of the subscription. | | `is_family_share` | boolean | | | `country_code` | string or null | ISO 3166-1 alpha-2. | | `currency` | string or null | ISO 4217. | | `price` | number or null | USD. Money moved only on INITIAL_PURCHASE, RENEWAL, NON_RENEWING_PURCHASE, REFUND_REVERSED and refunds (negative); 0 on other events. | | `price_in_purchased_currency` | number or null | Same as price, in `currency`. | | `subscriber_attributes` | object | | | `store` | `APP_STORE`, `MAC_APP_STORE`, `PLAY_STORE`, `AMAZON`, `STRIPE`, `RC_BILLING`, `PROMOTIONAL`, `TEST_STORE`, `PADDLE`, `ROKU`, `EXTERNAL` | | | `takehome_percentage` | number | 1 minus the estimated store commission. | | `tax_percentage` | number | Always 0 today. | | `commission_percentage` | number | Estimated store commission (0.3 for App Store and Google Play, 0 for Test Store). | | `offer_code` | null | | Example: ```json { "api_version": "1.0", "event": { "id": "457C5C58-FF87-4CF3-908A-EB31E34846E1", "type": "NON_RENEWING_PURCHASE", "event_timestamp_ms": 1790800924176, "app_id": "appvnrm0a5h", "app_user_id": "user_life", "original_app_user_id": "user_life", "aliases": [ "user_life" ], "product_id": "pro_lifetime", "period_type": "NORMAL", "purchased_at_ms": 1790800924176, "expiration_at_ms": null, "environment": "SANDBOX", "entitlement_id": null, "entitlement_ids": [ "pro" ], "presented_offering_id": null, "transaction_id": "test_1790800924176_a2fda0f6-622f-4e5d-9a5c-003144fb7a2f", "original_transaction_id": "test_1790800924176_a2fda0f6-622f-4e5d-9a5c-003144fb7a2f", "is_family_share": false, "country_code": null, "currency": "USD", "price": 49.99, "price_in_purchased_currency": 49.99, "subscriber_attributes": {}, "store": "TEST_STORE", "takehome_percentage": 1, "tax_percentage": 0, "commission_percentage": 0, "offer_code": null } } ``` ## SUBSCRIPTION_PAUSED A Google Play subscription is scheduled to pause. It will not renew at the end of the period. | Field | Type | Description | |---|---|---| | `id` | string | Unique event id (upper-case UUID). Deduplicate on it. | | `type` | string | Event type. | | `event_timestamp_ms` | integer | When RevenueDot recorded the event. Epoch milliseconds. | | `app_id` | string | RevenueDot app id. Left out for promotional grants. | | `app_user_id` | string | The app user id the event is about (a non-anonymous alias when there is one). | | `original_app_user_id` | string | The customer's first app user id. | | `aliases` | array of string | Every app user id of the customer. | | `product_id` | string | Store product id. For PRODUCT_CHANGE, the product the customer changed from. | | `period_type` | `NORMAL`, `TRIAL`, `INTRO`, `PROMOTIONAL`, `PREPAID` | | | `purchased_at_ms` | integer | Start of the period. Epoch milliseconds. | | `expiration_at_ms` | integer or null | End of the period, or null for lifetime. Epoch milliseconds. | | `environment` | `PRODUCTION`, `SANDBOX` | | | `entitlement_id` | null | Always null (deprecated in RevenueCat's payload). | | `entitlement_ids` | array of string | Lookup keys of the entitlements the product unlocks, or null. | | `presented_offering_id` | string or null | Offering the purchase was made from, when the SDK sent it. | | `transaction_id` | string or null | Store transaction id of this period. | | `original_transaction_id` | string or null | First transaction id of the subscription. | | `is_family_share` | boolean | | | `country_code` | string or null | ISO 3166-1 alpha-2. | | `currency` | string or null | ISO 4217. | | `price` | number or null | USD. Money moved only on INITIAL_PURCHASE, RENEWAL, NON_RENEWING_PURCHASE, REFUND_REVERSED and refunds (negative); 0 on other events. | | `price_in_purchased_currency` | number or null | Same as price, in `currency`. | | `subscriber_attributes` | object | | | `store` | `APP_STORE`, `MAC_APP_STORE`, `PLAY_STORE`, `AMAZON`, `STRIPE`, `RC_BILLING`, `PROMOTIONAL`, `TEST_STORE`, `PADDLE`, `ROKU`, `EXTERNAL` | | | `takehome_percentage` | number | 1 minus the estimated store commission. | | `tax_percentage` | number | Always 0 today. | | `commission_percentage` | number | Estimated store commission (0.3 for App Store and Google Play, 0 for Test Store). | | `offer_code` | null | | | `auto_resume_at_ms` | integer or null | When it resumes. Epoch milliseconds. | Example: ```json { "api_version": "1.0", "event": { "id": "0C1F6F2E-5B7A-4E8B-9D1C-3A2B1C0D9E02", "type": "SUBSCRIPTION_PAUSED", "event_timestamp_ms": 1790800914034, "app_id": "app9l7z3oij", "app_user_id": "user_1", "original_app_user_id": "user_1", "aliases": [ "user_1" ], "product_id": "pro", "period_type": "NORMAL", "purchased_at_ms": 1790800914000, "expiration_at_ms": 1793392914000, "environment": "PRODUCTION", "entitlement_id": null, "entitlement_ids": [ "pro" ], "presented_offering_id": null, "transaction_id": "GPA.3372-1234-5678-90123..1", "original_transaction_id": "GPA.3372-1234-5678-90123", "is_family_share": false, "country_code": "US", "currency": "USD", "price": 0, "price_in_purchased_currency": 0, "subscriber_attributes": {}, "store": "PLAY_STORE", "takehome_percentage": 0.7, "tax_percentage": 0, "commission_percentage": 0.3, "offer_code": null, "auto_resume_at_ms": 1796071314000 } } ``` ## EXPIRATION Access ended: the period ran out, billing retry gave up or the subscription paused. | Field | Type | Description | |---|---|---| | `id` | string | Unique event id (upper-case UUID). Deduplicate on it. | | `type` | string | Event type. | | `event_timestamp_ms` | integer | When RevenueDot recorded the event. Epoch milliseconds. | | `app_id` | string | RevenueDot app id. Left out for promotional grants. | | `app_user_id` | string | The app user id the event is about (a non-anonymous alias when there is one). | | `original_app_user_id` | string | The customer's first app user id. | | `aliases` | array of string | Every app user id of the customer. | | `product_id` | string | Store product id. For PRODUCT_CHANGE, the product the customer changed from. | | `period_type` | `NORMAL`, `TRIAL`, `INTRO`, `PROMOTIONAL`, `PREPAID` | | | `purchased_at_ms` | integer | Start of the period. Epoch milliseconds. | | `expiration_at_ms` | integer or null | End of the period, or null for lifetime. Epoch milliseconds. | | `environment` | `PRODUCTION`, `SANDBOX` | | | `entitlement_id` | null | Always null (deprecated in RevenueCat's payload). | | `entitlement_ids` | array of string | Lookup keys of the entitlements the product unlocks, or null. | | `presented_offering_id` | string or null | Offering the purchase was made from, when the SDK sent it. | | `transaction_id` | string or null | Store transaction id of this period. | | `original_transaction_id` | string or null | First transaction id of the subscription. | | `is_family_share` | boolean | | | `country_code` | string or null | ISO 3166-1 alpha-2. | | `currency` | string or null | ISO 4217. | | `price` | number or null | USD. Money moved only on INITIAL_PURCHASE, RENEWAL, NON_RENEWING_PURCHASE, REFUND_REVERSED and refunds (negative); 0 on other events. | | `price_in_purchased_currency` | number or null | Same as price, in `currency`. | | `subscriber_attributes` | object | | | `store` | `APP_STORE`, `MAC_APP_STORE`, `PLAY_STORE`, `AMAZON`, `STRIPE`, `RC_BILLING`, `PROMOTIONAL`, `TEST_STORE`, `PADDLE`, `ROKU`, `EXTERNAL` | | | `takehome_percentage` | number | 1 minus the estimated store commission. | | `tax_percentage` | number | Always 0 today. | | `commission_percentage` | number | Estimated store commission (0.3 for App Store and Google Play, 0 for Test Store). | | `offer_code` | null | | | `expiration_reason` | `UNSUBSCRIBE`, `BILLING_ERROR`, `DEVELOPER_INITIATED`, `PRICE_INCREASE`, `CUSTOMER_SUPPORT`, `UNKNOWN`, `SUBSCRIPTION_PAUSED` | | Example: ```json { "api_version": "1.0", "event": { "id": "91A2F69A-FCA7-4C27-9EAE-65C8207BAD7D", "type": "EXPIRATION", "event_timestamp_ms": 1790800924128, "app_id": "appvnrm0a5h", "app_user_id": "user_expire", "original_app_user_id": "user_expire", "aliases": [ "user_expire" ], "product_id": "pro_monthly", "period_type": "NORMAL", "purchased_at_ms": 1788122524128, "expiration_at_ms": 1790800924128, "environment": "SANDBOX", "entitlement_id": null, "entitlement_ids": [ "pro" ], "presented_offering_id": null, "transaction_id": "test_1790800924128_a715897b-5461-4c93-abb6-d8d3ea3d11cd", "original_transaction_id": "test_1790800924128_a715897b-5461-4c93-abb6-d8d3ea3d11cd", "is_family_share": false, "country_code": null, "currency": "USD", "price": 0, "price_in_purchased_currency": 0, "subscriber_attributes": {}, "store": "TEST_STORE", "takehome_percentage": 1, "tax_percentage": 0, "commission_percentage": 0, "offer_code": null, "expiration_reason": "UNSUBSCRIBE" } } ``` ## BILLING_ISSUE A renewal charge failed. The store retries; access may continue in a grace period. | Field | Type | Description | |---|---|---| | `id` | string | Unique event id (upper-case UUID). Deduplicate on it. | | `type` | string | Event type. | | `event_timestamp_ms` | integer | When RevenueDot recorded the event. Epoch milliseconds. | | `app_id` | string | RevenueDot app id. Left out for promotional grants. | | `app_user_id` | string | The app user id the event is about (a non-anonymous alias when there is one). | | `original_app_user_id` | string | The customer's first app user id. | | `aliases` | array of string | Every app user id of the customer. | | `product_id` | string | Store product id. For PRODUCT_CHANGE, the product the customer changed from. | | `period_type` | `NORMAL`, `TRIAL`, `INTRO`, `PROMOTIONAL`, `PREPAID` | | | `purchased_at_ms` | integer | Start of the period. Epoch milliseconds. | | `expiration_at_ms` | integer or null | End of the period, or null for lifetime. Epoch milliseconds. | | `environment` | `PRODUCTION`, `SANDBOX` | | | `entitlement_id` | null | Always null (deprecated in RevenueCat's payload). | | `entitlement_ids` | array of string | Lookup keys of the entitlements the product unlocks, or null. | | `presented_offering_id` | string or null | Offering the purchase was made from, when the SDK sent it. | | `transaction_id` | string or null | Store transaction id of this period. | | `original_transaction_id` | string or null | First transaction id of the subscription. | | `is_family_share` | boolean | | | `country_code` | string or null | ISO 3166-1 alpha-2. | | `currency` | string or null | ISO 4217. | | `price` | number or null | USD. Money moved only on INITIAL_PURCHASE, RENEWAL, NON_RENEWING_PURCHASE, REFUND_REVERSED and refunds (negative); 0 on other events. | | `price_in_purchased_currency` | number or null | Same as price, in `currency`. | | `subscriber_attributes` | object | | | `store` | `APP_STORE`, `MAC_APP_STORE`, `PLAY_STORE`, `AMAZON`, `STRIPE`, `RC_BILLING`, `PROMOTIONAL`, `TEST_STORE`, `PADDLE`, `ROKU`, `EXTERNAL` | | | `takehome_percentage` | number | 1 minus the estimated store commission. | | `tax_percentage` | number | Always 0 today. | | `commission_percentage` | number | Estimated store commission (0.3 for App Store and Google Play, 0 for Test Store). | | `offer_code` | null | | | `grace_period_expiration_at_ms` | integer or null | End of the grace period, or null when there is none. Epoch milliseconds. | Example: ```json { "api_version": "1.0", "event": { "id": "59A0C090-CC89-458B-BDF2-57D9F1447C19", "type": "BILLING_ISSUE", "event_timestamp_ms": 1790800924056, "app_id": "appvnrm0a5h", "app_user_id": "user_billing_issue", "original_app_user_id": "user_billing_issue", "aliases": [ "user_billing_issue" ], "product_id": "pro_monthly", "period_type": "NORMAL", "purchased_at_ms": 1788122524056, "expiration_at_ms": 1790800924056, "environment": "SANDBOX", "entitlement_id": null, "entitlement_ids": [ "pro" ], "presented_offering_id": null, "transaction_id": "test_1790800924056_16f6f7bd-6289-454a-b3b8-1fac8424f8a0", "original_transaction_id": "test_1790800924056_16f6f7bd-6289-454a-b3b8-1fac8424f8a0", "is_family_share": false, "country_code": null, "currency": "USD", "price": 0, "price_in_purchased_currency": 0, "subscriber_attributes": {}, "store": "TEST_STORE", "takehome_percentage": 1, "tax_percentage": 0, "commission_percentage": 0, "offer_code": null, "grace_period_expiration_at_ms": 1791405724056 } } ``` ## PRODUCT_CHANGE The customer changed product: an upgrade now, or a downgrade or crossgrade scheduled for the next renewal. `product_id` is the old product. | Field | Type | Description | |---|---|---| | `id` | string | Unique event id (upper-case UUID). Deduplicate on it. | | `type` | string | Event type. | | `event_timestamp_ms` | integer | When RevenueDot recorded the event. Epoch milliseconds. | | `app_id` | string | RevenueDot app id. Left out for promotional grants. | | `app_user_id` | string | The app user id the event is about (a non-anonymous alias when there is one). | | `original_app_user_id` | string | The customer's first app user id. | | `aliases` | array of string | Every app user id of the customer. | | `product_id` | string | Store product id. For PRODUCT_CHANGE, the product the customer changed from. | | `period_type` | `NORMAL`, `TRIAL`, `INTRO`, `PROMOTIONAL`, `PREPAID` | | | `purchased_at_ms` | integer | Start of the period. Epoch milliseconds. | | `expiration_at_ms` | integer or null | End of the period, or null for lifetime. Epoch milliseconds. | | `environment` | `PRODUCTION`, `SANDBOX` | | | `entitlement_id` | null | Always null (deprecated in RevenueCat's payload). | | `entitlement_ids` | array of string | Lookup keys of the entitlements the product unlocks, or null. | | `presented_offering_id` | string or null | Offering the purchase was made from, when the SDK sent it. | | `transaction_id` | string or null | Store transaction id of this period. | | `original_transaction_id` | string or null | First transaction id of the subscription. | | `is_family_share` | boolean | | | `country_code` | string or null | ISO 3166-1 alpha-2. | | `currency` | string or null | ISO 4217. | | `price` | number or null | USD. Money moved only on INITIAL_PURCHASE, RENEWAL, NON_RENEWING_PURCHASE, REFUND_REVERSED and refunds (negative); 0 on other events. | | `price_in_purchased_currency` | number or null | Same as price, in `currency`. | | `subscriber_attributes` | object | | | `store` | `APP_STORE`, `MAC_APP_STORE`, `PLAY_STORE`, `AMAZON`, `STRIPE`, `RC_BILLING`, `PROMOTIONAL`, `TEST_STORE`, `PADDLE`, `ROKU`, `EXTERNAL` | | | `takehome_percentage` | number | 1 minus the estimated store commission. | | `tax_percentage` | number | Always 0 today. | | `commission_percentage` | number | Estimated store commission (0.3 for App Store and Google Play, 0 for Test Store). | | `offer_code` | null | | | `new_product_id` | string | The product the customer changed to. | Example: ```json { "api_version": "1.0", "event": { "id": "0C1F6F2E-5B7A-4E8B-9D1C-3A2B1C0D9E03", "type": "PRODUCT_CHANGE", "event_timestamp_ms": 1790800914034, "app_id": "appugfw01uy", "app_user_id": "user_1", "original_app_user_id": "user_1", "aliases": [ "user_1" ], "product_id": "pro_monthly", "new_product_id": "pro_annual", "period_type": "NORMAL", "purchased_at_ms": 1790800914000, "expiration_at_ms": 1793392914000, "environment": "PRODUCTION", "entitlement_id": null, "entitlement_ids": [ "pro" ], "presented_offering_id": null, "transaction_id": "2000000912345679", "original_transaction_id": "2000000912345678", "is_family_share": false, "country_code": "US", "currency": "USD", "price": 0, "price_in_purchased_currency": 0, "subscriber_attributes": {}, "store": "APP_STORE", "takehome_percentage": 0.7, "tax_percentage": 0, "commission_percentage": 0.3, "offer_code": null } } ``` ## SUBSCRIPTION_EXTENDED The current period got longer without a new payment: an App Store renewal extension or a Google Play deferral. | Field | Type | Description | |---|---|---| | `id` | string | Unique event id (upper-case UUID). Deduplicate on it. | | `type` | string | Event type. | | `event_timestamp_ms` | integer | When RevenueDot recorded the event. Epoch milliseconds. | | `app_id` | string | RevenueDot app id. Left out for promotional grants. | | `app_user_id` | string | The app user id the event is about (a non-anonymous alias when there is one). | | `original_app_user_id` | string | The customer's first app user id. | | `aliases` | array of string | Every app user id of the customer. | | `product_id` | string | Store product id. For PRODUCT_CHANGE, the product the customer changed from. | | `period_type` | `NORMAL`, `TRIAL`, `INTRO`, `PROMOTIONAL`, `PREPAID` | | | `purchased_at_ms` | integer | Start of the period. Epoch milliseconds. | | `expiration_at_ms` | integer or null | End of the period, or null for lifetime. Epoch milliseconds. | | `environment` | `PRODUCTION`, `SANDBOX` | | | `entitlement_id` | null | Always null (deprecated in RevenueCat's payload). | | `entitlement_ids` | array of string | Lookup keys of the entitlements the product unlocks, or null. | | `presented_offering_id` | string or null | Offering the purchase was made from, when the SDK sent it. | | `transaction_id` | string or null | Store transaction id of this period. | | `original_transaction_id` | string or null | First transaction id of the subscription. | | `is_family_share` | boolean | | | `country_code` | string or null | ISO 3166-1 alpha-2. | | `currency` | string or null | ISO 4217. | | `price` | number or null | USD. Money moved only on INITIAL_PURCHASE, RENEWAL, NON_RENEWING_PURCHASE, REFUND_REVERSED and refunds (negative); 0 on other events. | | `price_in_purchased_currency` | number or null | Same as price, in `currency`. | | `subscriber_attributes` | object | | | `store` | `APP_STORE`, `MAC_APP_STORE`, `PLAY_STORE`, `AMAZON`, `STRIPE`, `RC_BILLING`, `PROMOTIONAL`, `TEST_STORE`, `PADDLE`, `ROKU`, `EXTERNAL` | | | `takehome_percentage` | number | 1 minus the estimated store commission. | | `tax_percentage` | number | Always 0 today. | | `commission_percentage` | number | Estimated store commission (0.3 for App Store and Google Play, 0 for Test Store). | | `offer_code` | null | | Example: ```json { "api_version": "1.0", "event": { "id": "0C1F6F2E-5B7A-4E8B-9D1C-3A2B1C0D9E04", "type": "SUBSCRIPTION_EXTENDED", "event_timestamp_ms": 1790800914034, "app_id": "appugfw01uy", "app_user_id": "user_1", "original_app_user_id": "user_1", "aliases": [ "user_1" ], "product_id": "pro_monthly", "period_type": "NORMAL", "purchased_at_ms": 1790800914000, "expiration_at_ms": 1793997714000, "environment": "PRODUCTION", "entitlement_id": null, "entitlement_ids": [ "pro" ], "presented_offering_id": null, "transaction_id": "2000000912345679", "original_transaction_id": "2000000912345678", "is_family_share": false, "country_code": "US", "currency": "USD", "price": 0, "price_in_purchased_currency": 0, "subscriber_attributes": {}, "store": "APP_STORE", "takehome_percentage": 0.7, "tax_percentage": 0, "commission_percentage": 0.3, "offer_code": null } } ``` ## REFUND_REVERSED A refund was reversed and access is back. | Field | Type | Description | |---|---|---| | `id` | string | Unique event id (upper-case UUID). Deduplicate on it. | | `type` | string | Event type. | | `event_timestamp_ms` | integer | When RevenueDot recorded the event. Epoch milliseconds. | | `app_id` | string | RevenueDot app id. Left out for promotional grants. | | `app_user_id` | string | The app user id the event is about (a non-anonymous alias when there is one). | | `original_app_user_id` | string | The customer's first app user id. | | `aliases` | array of string | Every app user id of the customer. | | `product_id` | string | Store product id. For PRODUCT_CHANGE, the product the customer changed from. | | `period_type` | `NORMAL`, `TRIAL`, `INTRO`, `PROMOTIONAL`, `PREPAID` | | | `purchased_at_ms` | integer | Start of the period. Epoch milliseconds. | | `expiration_at_ms` | integer or null | End of the period, or null for lifetime. Epoch milliseconds. | | `environment` | `PRODUCTION`, `SANDBOX` | | | `entitlement_id` | null | Always null (deprecated in RevenueCat's payload). | | `entitlement_ids` | array of string | Lookup keys of the entitlements the product unlocks, or null. | | `presented_offering_id` | string or null | Offering the purchase was made from, when the SDK sent it. | | `transaction_id` | string or null | Store transaction id of this period. | | `original_transaction_id` | string or null | First transaction id of the subscription. | | `is_family_share` | boolean | | | `country_code` | string or null | ISO 3166-1 alpha-2. | | `currency` | string or null | ISO 4217. | | `price` | number or null | USD. Money moved only on INITIAL_PURCHASE, RENEWAL, NON_RENEWING_PURCHASE, REFUND_REVERSED and refunds (negative); 0 on other events. | | `price_in_purchased_currency` | number or null | Same as price, in `currency`. | | `subscriber_attributes` | object | | | `store` | `APP_STORE`, `MAC_APP_STORE`, `PLAY_STORE`, `AMAZON`, `STRIPE`, `RC_BILLING`, `PROMOTIONAL`, `TEST_STORE`, `PADDLE`, `ROKU`, `EXTERNAL` | | | `takehome_percentage` | number | 1 minus the estimated store commission. | | `tax_percentage` | number | Always 0 today. | | `commission_percentage` | number | Estimated store commission (0.3 for App Store and Google Play, 0 for Test Store). | | `offer_code` | null | | Example: ```json { "api_version": "1.0", "event": { "id": "0C1F6F2E-5B7A-4E8B-9D1C-3A2B1C0D9E05", "type": "REFUND_REVERSED", "event_timestamp_ms": 1790800914034, "app_id": "appugfw01uy", "app_user_id": "user_1", "original_app_user_id": "user_1", "aliases": [ "user_1" ], "product_id": "pro_monthly", "period_type": "NORMAL", "purchased_at_ms": 1790800914000, "expiration_at_ms": 1793392914000, "environment": "PRODUCTION", "entitlement_id": null, "entitlement_ids": [ "pro" ], "presented_offering_id": null, "transaction_id": "2000000912345679", "original_transaction_id": "2000000912345678", "is_family_share": false, "country_code": "US", "currency": "USD", "price": 9.99, "price_in_purchased_currency": 9.99, "subscriber_attributes": {}, "store": "APP_STORE", "takehome_percentage": 0.7, "tax_percentage": 0, "commission_percentage": 0.3, "offer_code": null } } ``` ## PRICE_INCREASE_CONSENT_REQUIRED The store asks the customer to accept a price increase. | Field | Type | Description | |---|---|---| | `id` | string | Unique event id (upper-case UUID). Deduplicate on it. | | `type` | string | Event type. | | `event_timestamp_ms` | integer | When RevenueDot recorded the event. Epoch milliseconds. | | `app_id` | string | RevenueDot app id. Left out for promotional grants. | | `app_user_id` | string | The app user id the event is about (a non-anonymous alias when there is one). | | `original_app_user_id` | string | The customer's first app user id. | | `aliases` | array of string | Every app user id of the customer. | | `product_id` | string | Store product id. For PRODUCT_CHANGE, the product the customer changed from. | | `transaction_id` | string or null | Store transaction id of this period. | | `original_transaction_id` | string or null | First transaction id of the subscription. | | `store` | `APP_STORE`, `MAC_APP_STORE`, `PLAY_STORE`, `AMAZON`, `STRIPE`, `RC_BILLING`, `PROMOTIONAL`, `TEST_STORE`, `PADDLE`, `ROKU`, `EXTERNAL` | | | `environment` | `PRODUCTION`, `SANDBOX` | | | `currency` | string or null | ISO 4217. | | `country_code` | string or null | ISO 3166-1 alpha-2. | | `subscriber_attributes` | object | | Example: ```json { "api_version": "1.0", "event": { "id": "0C1F6F2E-5B7A-4E8B-9D1C-3A2B1C0D9E06", "type": "PRICE_INCREASE_CONSENT_REQUIRED", "event_timestamp_ms": 1790800914034, "app_id": "appugfw01uy", "app_user_id": "user_1", "original_app_user_id": "user_1", "aliases": [ "user_1" ], "product_id": "pro_monthly", "environment": "PRODUCTION", "transaction_id": "2000000912345679", "original_transaction_id": "2000000912345678", "country_code": "US", "currency": "USD", "subscriber_attributes": {}, "store": "APP_STORE" } } ``` ## PRICE_INCREASE_CONSENT_APPROVED The customer accepted the price increase. | Field | Type | Description | |---|---|---| | `id` | string | Unique event id (upper-case UUID). Deduplicate on it. | | `type` | string | Event type. | | `event_timestamp_ms` | integer | When RevenueDot recorded the event. Epoch milliseconds. | | `app_id` | string | RevenueDot app id. Left out for promotional grants. | | `app_user_id` | string | The app user id the event is about (a non-anonymous alias when there is one). | | `original_app_user_id` | string | The customer's first app user id. | | `aliases` | array of string | Every app user id of the customer. | | `product_id` | string | Store product id. For PRODUCT_CHANGE, the product the customer changed from. | | `transaction_id` | string or null | Store transaction id of this period. | | `original_transaction_id` | string or null | First transaction id of the subscription. | | `store` | `APP_STORE`, `MAC_APP_STORE`, `PLAY_STORE`, `AMAZON`, `STRIPE`, `RC_BILLING`, `PROMOTIONAL`, `TEST_STORE`, `PADDLE`, `ROKU`, `EXTERNAL` | | | `environment` | `PRODUCTION`, `SANDBOX` | | | `currency` | string or null | ISO 4217. | | `country_code` | string or null | ISO 3166-1 alpha-2. | | `subscriber_attributes` | object | | Example: ```json { "api_version": "1.0", "event": { "id": "0C1F6F2E-5B7A-4E8B-9D1C-3A2B1C0D9E07", "type": "PRICE_INCREASE_CONSENT_APPROVED", "event_timestamp_ms": 1790800914034, "app_id": "appugfw01uy", "app_user_id": "user_1", "original_app_user_id": "user_1", "aliases": [ "user_1" ], "product_id": "pro_monthly", "environment": "PRODUCTION", "transaction_id": "2000000912345679", "original_transaction_id": "2000000912345678", "country_code": "US", "currency": "USD", "subscriber_attributes": {}, "store": "APP_STORE" } } ``` ## TRANSFER A purchase moved to another customer because that customer restored it (transfer behaviour `transfer` or `transfer_if_no_active`). | Field | Type | Description | |---|---|---| | `id` | string | Unique event id (upper-case UUID). Deduplicate on it. | | `type` | string | Event type. | | `event_timestamp_ms` | integer | When RevenueDot recorded the event. Epoch milliseconds. | | `app_id` | string | RevenueDot app id. Left out for promotional grants. | | `store` | `APP_STORE`, `MAC_APP_STORE`, `PLAY_STORE`, `AMAZON`, `STRIPE`, `RC_BILLING`, `PROMOTIONAL`, `TEST_STORE`, `PADDLE`, `ROKU`, `EXTERNAL` | | | `environment` | `PRODUCTION`, `SANDBOX` | | | `transferred_from` | array of string | App user ids of the previous owner. | | `transferred_to` | array of string | App user ids of the new owner. | | `subscriber_attributes` | object | | Example: ```json { "api_version": "1.0", "event": { "id": "90CB2D5B-DDD2-4F25-9430-4E5290CC493A", "type": "TRANSFER", "event_timestamp_ms": 1790800924235, "app_id": "appvnrm0a5h", "environment": "SANDBOX", "subscriber_attributes": {}, "store": "TEST_STORE", "transferred_from": [ "alice" ], "transferred_to": [ "bob" ] } } ``` ## TEST Sent by the dashboard's "Send test event" or `POST .../integrations/webhooks/{id}/test`. Shaped like a purchase. | Field | Type | Description | |---|---|---| | `id` | string | Unique event id (upper-case UUID). Deduplicate on it. | | `type` | string | Event type. | | `event_timestamp_ms` | integer | When RevenueDot recorded the event. Epoch milliseconds. | | `app_id` | string | RevenueDot app id. Left out for promotional grants. | | `app_user_id` | string | The app user id the event is about (a non-anonymous alias when there is one). | | `original_app_user_id` | string | The customer's first app user id. | | `aliases` | array of string | Every app user id of the customer. | | `product_id` | string | Store product id. For PRODUCT_CHANGE, the product the customer changed from. | | `period_type` | `NORMAL`, `TRIAL`, `INTRO`, `PROMOTIONAL`, `PREPAID` | | | `purchased_at_ms` | integer | Start of the period. Epoch milliseconds. | | `expiration_at_ms` | integer or null | End of the period, or null for lifetime. Epoch milliseconds. | | `environment` | `PRODUCTION`, `SANDBOX` | | | `entitlement_id` | null | Always null (deprecated in RevenueCat's payload). | | `entitlement_ids` | array of string | Lookup keys of the entitlements the product unlocks, or null. | | `presented_offering_id` | string or null | Offering the purchase was made from, when the SDK sent it. | | `transaction_id` | string or null | Store transaction id of this period. | | `original_transaction_id` | string or null | First transaction id of the subscription. | | `is_family_share` | boolean | | | `country_code` | string or null | ISO 3166-1 alpha-2. | | `currency` | string or null | ISO 4217. | | `price` | number or null | USD. Money moved only on INITIAL_PURCHASE, RENEWAL, NON_RENEWING_PURCHASE, REFUND_REVERSED and refunds (negative); 0 on other events. | | `price_in_purchased_currency` | number or null | Same as price, in `currency`. | | `subscriber_attributes` | object | | | `store` | `APP_STORE`, `MAC_APP_STORE`, `PLAY_STORE`, `AMAZON`, `STRIPE`, `RC_BILLING`, `PROMOTIONAL`, `TEST_STORE`, `PADDLE`, `ROKU`, `EXTERNAL` | | | `takehome_percentage` | number | 1 minus the estimated store commission. | | `tax_percentage` | number | Always 0 today. | | `commission_percentage` | number | Estimated store commission (0.3 for App Store and Google Play, 0 for Test Store). | | `offer_code` | null | | Example: ```json { "api_version": "1.0", "event": { "id": "F48A3CEE-2785-4001-9A4D-590F95C1A9F0", "type": "TEST", "event_timestamp_ms": 1790800924267, "app_id": "appvnrm0a5h", "app_user_id": "$RCAnonymousID:10daafcef1da4cea82bdf031565747fc", "original_app_user_id": "$RCAnonymousID:10daafcef1da4cea82bdf031565747fc", "aliases": [ "$RCAnonymousID:10daafcef1da4cea82bdf031565747fc" ], "product_id": "test_product", "period_type": "NORMAL", "purchased_at_ms": 1790800924267, "expiration_at_ms": 1793392924267, "environment": "SANDBOX", "entitlement_id": null, "entitlement_ids": null, "presented_offering_id": null, "transaction_id": "test_transaction_id", "original_transaction_id": "test_original_transaction_id", "is_family_share": false, "country_code": "US", "currency": "USD", "price": 0, "price_in_purchased_currency": 0, "subscriber_attributes": {}, "store": "TEST_STORE", "takehome_percentage": 1, "tax_percentage": 0, "commission_percentage": 0, "offer_code": null } } ``` ## Related - [Webhooks guide](https://revenuedot.app/docs/guides/webhooks.md): signature verification code, retries, testing - [Subscriptions and events](https://revenuedot.app/docs/concepts/subscriptions-and-events.md): which store change produces which event - [OpenAPI document](https://revenuedot.app/docs/api/openapi.yaml): the `webhooks` section === Help center === # Where do I find answers to common RevenueDot problems? Source: https://revenuedot.app/docs/help.md Description: The RevenueDot help center, grouped by topic. Each article answers one question, with the fix first. Pick the question closest to yours below. Each article starts with the answer, then lists causes and fixes. Also check [Known issues](https://revenuedot.app/docs/help/known-issues.md) before you dig in. ## Getting started - [Frequently asked questions](https://revenuedot.app/docs/help/faq.md): what RevenueDot is, what it costs, which stores and SDKs work today. - [Known issues and gaps as of 2026-09-30](https://revenuedot.app/docs/help/known-issues.md): what does not work yet, and the workaround for each. - [Troubleshooting by symptom](https://revenuedot.app/docs/help/troubleshooting.md): one table from symptom to cause to fix. ## Account - [What do I do if I forgot my RevenueDot password?](https://revenuedot.app/docs/help/forgot-password.md) ## Purchases and access - [Why is my entitlement not active?](https://revenuedot.app/docs/help/entitlement-not-active.md) - [Why does RevenueDot answer 4xx or 5xx to a receipt?](https://revenuedot.app/docs/help/receipt-errors-4xx-vs-5xx.md) - [How do I restore purchases?](https://revenuedot.app/docs/help/restore-purchases.md) - [How do I test purchases without real money?](https://revenuedot.app/docs/help/test-sandbox-purchases.md) ## SDK setup - [Why does the SDK report signature verification FAILED in proxy mode?](https://revenuedot.app/docs/help/signature-verification-failed.md) ## Notifications and webhooks - [Why are store notifications not arriving?](https://revenuedot.app/docs/help/store-notifications-not-arriving.md) - [Why are webhooks not arriving?](https://revenuedot.app/docs/help/webhooks-not-arriving.md) ## Still stuck? Open an issue on [GitHub](https://github.com/revenuedot/revenuedot/issues) with the request, the response body and the server log line. Report security problems privately through the **Report a vulnerability** button on the repository's Security tab. ## Related - [Quickstart](https://revenuedot.app/docs/getting-started/quickstart.md) - [Self-hosting](https://revenuedot.app/docs/guides/self-hosting.md) - [API reference](https://revenuedot.app/docs/api.md) --- # What do people most often ask about RevenueDot? Source: https://revenuedot.app/docs/help/faq.md Description: Short answers about what RevenueDot is, what it costs, its licenses, the stores and SDKs it supports, and what works today. RevenueDot is an open-source (AGPL-3.0), self-hostable backend for in-app purchases and subscriptions that works with the RevenueCat SDK. The answers below say what exists on 2026-09-30. ## Is RevenueDot an open-source RevenueCat alternative? Yes. RevenueDot implements the API that the RevenueCat SDKs call, so an app keeps its purchase code and points the SDK at a RevenueDot server with one setting, the proxy URL. The server code is on [GitHub](https://github.com/revenuedot/revenuedot). RevenueDot is not affiliated with RevenueCat. ## Can I self-host RevenueCat? No. RevenueCat's backend is a hosted service; only its SDKs are open source ([purchases-ios license](https://github.com/RevenueCat/purchases-ios/blob/main/LICENSE)). To run the backend yourself, you run RevenueDot with Docker and Postgres and keep the RevenueCat SDK in your app. See [Self-hosting](https://revenuedot.app/docs/guides/self-hosting.md). ## Do I have to change my app? One line, plus one setting on most platforms: 1. Set the SDK's proxy URL to your server before you configure the SDK. 2. Turn off the SDK's response-signature check, because RevenueDot cannot sign with RevenueCat's key. See [signature verification](https://revenuedot.app/docs/help/signature-verification-failed.md). ```swift // Point the SDK at your RevenueDot server; nothing else in the app changes. Purchases.proxyURL = URL(string: "https://revenuedot.example.com")! ``` Every platform's version of this line is in the [SDK guides](https://revenuedot.app/docs/sdks.md). ## How is RevenueDot different from RevenueCat? - **You can run it yourself.** Your purchase data lives in your own Postgres. - **The server is open source** under AGPL-3.0, so you can read the code that decides who gets access. - **It does far less today.** Paywalls, experiments, targeting, charts beyond the overview, Customer Center, virtual currencies and most integrations are not built. RevenueCat has all of these ([features](https://www.revenuecat.com/pricing)). - **It is newer.** RevenueCat has a longer track record as a hosted service. ## What does it cost? Self-hosting is free: you pay only for your server and database. RevenueCat's Pro plan is free up to $2,500 in monthly tracked revenue and then charges 1% of tracked revenue ([pricing](https://www.revenuecat.com/pricing)). RevenueDot Cloud is live at [app.revenuedot.app](https://app.revenuedot.app): sign-up is open and every account is on the free plan. Paid plans have not shipped. ## Which licenses apply? - The server and dashboard are AGPL-3.0. - The SDK forks keep RevenueCat's MIT license, with RevenueDot's copyright line added for the changes. - The importer CLI package, the MCP server and the agent skills are MIT. - The `ee/` folder is under the RevenueDot Enterprise License. Details are in [LICENSING.md](https://github.com/revenuedot/revenuedot/blob/main/LICENSING.md). If you change the server and let other people use it over a network, AGPL-3.0 asks you to offer them your changed source. ## Which stores are supported? Receipts are accepted today for four app types: | App type | Store | |---|---| | `app_store` | Apple App Store | | `mac_app_store` | Mac App Store | | `play_store` | Google Play | | `test_store` | RevenueDot Test Store (no real store) | Amazon, Stripe, Web Billing, Paddle and Roku apps can be created, but their receipts answer HTTP 400 with code 7662. See [Projects and apps](https://revenuedot.app/docs/concepts/projects-and-apps.md). ## Is it production-ready? No. The App Store and Google Play code is tested against mocked Apple and Google APIs only. No real App Store or Google Play sandbox purchase has run end to end yet. Use it for evaluation and testing, and keep RevenueCat for live customers until a release says otherwise. See [Known issues](https://revenuedot.app/docs/help/known-issues.md). ## Who owns the data? You do, when you self-host. Customers, purchases, receipts and events live in your Postgres database. Nothing is sent to RevenueDot. Back it up like any other production database: see [Backups](https://revenuedot.app/docs/guides/backups.md). ## Does it support StoreKit 2? Yes. The server verifies StoreKit 2 signed transactions (JWS) against Apple's certificate chain. It also accepts StoreKit 1 app receipts, but only when the app's App Store in-app purchase key is set, because an unsigned receipt could be forged. See [Connect the App Store](https://revenuedot.app/docs/guides/app-store.md). ## Does it support Expo? Yes, through `react-native-purchases`, the same package you use with RevenueCat. Call `Purchases.setProxyURL` before `configure`. Test Store purchases work in Expo Go and on the web today. See the [React Native guide](https://revenuedot.app/docs/sdks/react-native.md). ## Does it support current Google Play Billing? The server reads subscriptions through the Play Developer API (`subscriptionsv2`) and acknowledges purchases, which Google requires within 3 days ([Google docs](https://developer.android.com/google/play/billing/integrate#process)). Which Play Billing Library your app uses is decided by the RevenueCat Android SDK version you ship. See [Connect Google Play](https://revenuedot.app/docs/guides/google-play.md). ## Do I need a RevenueDot SDK? No. The stock RevenueCat SDKs work in proxy mode. The MIT forks exist to close gaps proxy mode cannot fix, such as response signing and Android diagnostics going to RevenueCat's servers. The forks are not published to any package registry yet. See [Connect your app](https://revenuedot.app/docs/getting-started/connect-your-app.md). ## Can I keep my RevenueCat API keys? Yes. The importer can copy your existing public app keys, so apps already in your users' hands keep working when they switch to your server. See [The importer](https://revenuedot.app/docs/migrate/importer.md). ## Can I migrate without losing subscribers? That is the design goal. Import customers and their current access, forward store notifications so RevenueCat and RevenueDot both stay current, compare them, then ship an app update with the proxy URL. See [Migrate from RevenueCat](https://revenuedot.app/docs/migrate.md). The importer is not on npm yet; run it from source. ## Do webhooks look the same as RevenueCat's? Yes, same body shape and the same `X-RevenueCat-Webhook-Signature` style of header, with the same retry schedule of 5, 10, 20, 40 and 80 minutes ([RevenueCat webhooks](https://www.revenuecat.com/docs/integrations/webhooks)). Some event types are accepted in filters but never sent yet. See [Webhooks](https://revenuedot.app/docs/guides/webhooks.md). ## Does it have a dashboard? Yes. The same process serves a dashboard at `/login` with overview metrics, customers, catalog, webhooks, API keys and setup health. ## Can I test without an App Store or Google Play account? Yes, with the Test Store: create a `test_store` app and use its `test_` key. See [Test Store](https://revenuedot.app/docs/guides/test-store.md). ## What happens to paywalls, experiments and Customer Center? They are not implemented. The SDK endpoints answer empty or 404 in the way that makes the SDK hide those features, so your app does not crash. Paywalls built in RevenueCat do not render against RevenueDot. ## Does the web SDK work? `purchases-js` works with Test Store (`test_`) keys against RevenueDot. Web Billing (`rcb_`), Stripe and Paddle purchases do not work yet. With the stock SDK, turn off analytics events (`flags: { collectAnalyticsEvents: false }`), because the stock SDK sends them to RevenueCat. See the [web guide](https://revenuedot.app/docs/sdks/web.md). ## Can AI agents set it up? Yes. The hosted MCP server is live at `https://mcp.revenuedot.app/mcp`: your client signs in with OAuth, or sends a secret key. It has 17 tools for the catalog, customers, access grants, webhooks and import status ([revenuedot/mcp](https://github.com/revenuedot/mcp)). The local version is on npm: `npx -y @revenuedot/mcp`. Agent skills for Claude Code, Codex and Cursor are in [revenuedot/agent-skills](https://github.com/revenuedot/agent-skills). ## Where do I report a bug? Open an issue on [GitHub](https://github.com/revenuedot/revenuedot/issues). Report security problems privately through the repository's Security tab, or email security@revenuedot.app. ## Related - [What is RevenueDot?](https://revenuedot.app/docs/getting-started.md) - [Known issues](https://revenuedot.app/docs/help/known-issues.md) - [Blog: Introducing RevenueDot](https://revenuedot.app/blog/introducing-revenuedot.md) --- # How do I fix common RevenueDot problems? Source: https://revenuedot.app/docs/help/troubleshooting.md Description: A symptom-to-fix list for the SDK, receipts, store notifications, webhooks, the dashboard and self-hosting, with the cause of each. Find your symptom below; each row gives the cause and the fix. Most problems come from one of three places: the SDK is not pointed at your server, a key or ID does not match the app, or the server cannot reach, or be reached by, the store or your backend. Always read the error's `message` and the server log first. RevenueDot writes the cause into both. ## SDK | Symptom | Cause | Fix | |---|---|---| | Requests still go to RevenueCat | The proxy URL is set after `configure`, or not at all | Set the proxy URL before `configure`. See the [SDK guides](https://revenuedot.app/docs/sdks.md) | | Every call fails with HTTP 401, code 7225 | The public key is wrong, belongs to another server, or has a typo | Copy the app's key from the app page, or `GET /v2/projects/{project_id}/apps/{app_id}/public_api_keys` | | Logs say entitlement verification FAILED | The stock SDK checks RevenueCat's signing key | Turn verification off. See [signature verification](https://revenuedot.app/docs/help/signature-verification-failed.md) | | Offerings are empty | No offering is marked current, or its packages point at products of another app | Mark an offering current and add this app's products to its packages. See [Offerings and packages](https://revenuedot.app/docs/concepts/offerings-and-packages.md) | | iOS says "No base price found for product" with a `test_` key | The server was built before the 2026-09-30 Test Store fix | Rebuild the server from the current source. See [Known issues](https://revenuedot.app/docs/help/known-issues.md) | | Web purchases with an `rcb_` key fail | Web Billing is not supported yet | Use a Test Store (`test_`) key. See [Known issues](https://revenuedot.app/docs/help/known-issues.md) | | An Android emulator cannot reach `http://localhost:8787` | `localhost` is the emulator itself | Use `http://10.0.2.2:8787` | | Android needs HTTPS | Android blocks cleartext HTTP by default | Use HTTPS, or allow cleartext for your dev host in the network security config | ## Receipts and access | Symptom | Cause | Fix | |---|---|---| | Purchase succeeds in the store, but the entitlement is not active | Product not attached, wrong app user ID, or the post failed | See [Why is my entitlement not active?](https://revenuedot.app/docs/help/entitlement-not-active.md) | | `/v1/receipts` answers 400, code 7103 | The receipt cannot be verified, or is for another bundle ID or package name | See [4xx or 5xx](https://revenuedot.app/docs/help/receipt-errors-4xx-vs-5xx.md) | | `/v1/receipts` answers 500, code 7234 | A StoreKit 1 receipt, and no App Store in-app purchase key | Add the key. See [Connect the App Store](https://revenuedot.app/docs/guides/app-store.md) | | `/v1/receipts` answers 503, code 7101 | Apple or Google failed, or the Google service account is wrong | Run **Verify credentials** on the app page. The SDK retries on its own | | `/v1/receipts` answers 400, code 7662 | The app is an Amazon, Stripe, Web Billing, Paddle or Roku app | Not supported yet. Use App Store, Mac App Store, Google Play or Test Store | | Restore fails with 7102 | Another user owns the purchase and the transfer behaviour forbids moving it | See [How do I restore purchases?](https://revenuedot.app/docs/help/restore-purchases.md) | | Xcode StoreKit test purchases fail with 7103 | Xcode signs them with its own certificate | Add the `xcode_certificate` credential. See [Test purchases](https://revenuedot.app/docs/help/test-sandbox-purchases.md) | ## Store notifications | Symptom | Cause | Fix | |---|---|---| | `notification_status` stays `waiting` | The store has the wrong URL, or cannot reach your server | Copy `notification_url` from setup health into App Store Connect or the Pub/Sub push subscription | | Status stays `received` | Notifications arrive only for purchases RevenueDot does not know | Normal before the first purchase. Send a test notification, or set `track_new_purchases` | | Status is `failing` with a bundle ID or package name message | The app's `bundle_id` or `package_name` does not match the store | Fix it on the app | | Google pushes get 401, code 7224 | `pubsub_audience` is set, and the push has no valid token | Turn on push authentication with the same audience, or remove the credential | Details: [Why are store notifications not arriving?](https://revenuedot.app/docs/help/store-notifications-not-arriving.md) ## Webhooks | Symptom | Cause | Fix | |---|---|---| | No deliveries in the log | A filter excludes the event, or the webhook was created after it | Check `environment`, `event_types` and `app_id` | | Deliveries `pending` or `failed` | Your backend answered something other than 200, or took more than 60 seconds | Answer 200 fast. Retries follow after 5, 10, 20, 40 and 80 minutes | | Signature check fails | Verifying parsed JSON instead of the raw body, or using another webhook's secret | Verify the raw body with this webhook's `whsec_` secret. See [Webhooks](https://revenuedot.app/docs/guides/webhooks.md) | | A self-hosted server cannot reach `localhost:3000` | `localhost` is the container itself | Use `http://host.docker.internal:3000` | Details: [Why are webhooks not arriving?](https://revenuedot.app/docs/help/webhooks-not-arriving.md) ## Dashboard | Symptom | Cause | Fix | |---|---|---| | `/` shows a small JSON document | The dashboard lives at `/login` | Open `http://localhost:8787/login` | | You cannot sign in after a fresh start | The account is created by signing up or by the seed script | Sign up at `/signup`, or sign in as the email the seed script printed | | You forgot your password, and no reset email arrives | The server has no `REVENUEDOT_SMTP_URL`, so emails go to the log | Copy the link from `docker compose logs revenuedot`, or run `revenuedot admin reset-password `. See [I forgot my password](https://revenuedot.app/docs/help/forgot-password.md) | ## Self-hosting | Symptom | Cause | Fix | |---|---|---| | `docker compose up` fails with "port is already allocated" | Something else uses port 8787 | Set `REVENUEDOT_PORT=8797` in `.env`, then use that port in URLs and in `RD_URL` for the seed script | | The server exits at start with a database error | `DATABASE_URL` is wrong, or Postgres is not ready | Compose sets `DATABASE_URL` for you. Outside Compose, set it to a `postgres://` URL. Without it, the server uses an embedded database in `./.data/dev`, which is for development only | | The server exits at start after an upgrade | A database migration failed. Migrations run every time the server starts, before it listens | Read the log line, fix the cause, restart. Restore your backup if you need to roll back. See [Upgrades](https://revenuedot.app/docs/guides/upgrades.md) | | Postgres rejects the password after you changed `POSTGRES_PASSWORD` | The password is stored in the data volume at first start | Change it inside Postgres too with `ALTER USER`, or start over with `docker compose down -v`, which deletes all data | | `REVENUEDOT_SIGNING_KEY` seems ignored | The shipped `docker-compose.yml` passes only `DATABASE_URL` and `PORT` to the container | Add `REVENUEDOT_SIGNING_KEY: ${REVENUEDOT_SIGNING_KEY}` under the `revenuedot` service's `environment` | | The server exits with "REVENUEDOT_SIGNING_KEY must be the base64 of a 32-byte Ed25519 private key seed" | The value is not a valid seed | Generate one with `pnpm tsx scripts/signing-keygen.ts` | | `/.well-known/revenuedot-signing-key` answers 404 | No signing key is set | Set `REVENUEDOT_SIGNING_KEY`. See [Trusted Entitlements](https://revenuedot.app/docs/guides/trusted-entitlements.md) | | Expirations and webhooks happen late | They run in a background job every 30 seconds | Expected. Run one server container per database for now | ## Related - [Self-hosting](https://revenuedot.app/docs/guides/self-hosting.md) - [Going to production](https://revenuedot.app/docs/guides/going-to-production.md) - [Known issues](https://revenuedot.app/docs/help/known-issues.md) --- # What are the known issues and gaps in RevenueDot? Source: https://revenuedot.app/docs/help/known-issues.md Description: Known issues as of 2026-09-30: what does not work yet, and the workaround for each. The list below is complete as of **2026-09-30**. The biggest gap: **no real App Store or Google Play sandbox purchase has run end to end yet**. Store support is tested against mocked Apple and Google APIs only. Each item has a workaround where one exists. ## Stores and purchases 1. **Real store purchases are untested end to end.** The App Store and Google Play code passes tests against mocked Apple and Google APIs. - Workaround: test with App Store sandbox and Google Play license testers, report what you find, and keep live customers on your current backend. See [Test purchases](https://revenuedot.app/docs/help/test-sandbox-purchases.md). 2. **Only four app types accept receipts:** `app_store`, `mac_app_store`, `play_store` and `test_store`. Amazon, Stripe, Web Billing (`rcb_`), Paddle and Roku receipts answer HTTP 400, code 7662. - Workaround: none yet. 3. **StoreKit 1 receipts need the App Store in-app purchase key.** Without it, RevenueDot answers HTTP 500, code 7234, so the SDK keeps retrying. - Workaround: add the key. For local development only, set the `allow_unsigned_receipts` credential. See [Connect the App Store](https://revenuedot.app/docs/guides/app-store.md). 4. **The App Store app-specific shared secret is stored but not used.** RevenueDot verifies with the in-app purchase key and Apple's signed transactions instead. - Workaround: add the in-app purchase key. 5. **USD values can differ slightly from RevenueCat's.** RevenueDot converts at the purchase date's rate from the ECB (about 30 currencies) and the [currency-api](https://github.com/fawazahmed0/exchange-api) (every other currency); RevenueCat uses Open Exchange Rates. The currency-api's history starts on 2024-03-02, so older purchases in currencies the ECB does not publish use that day's rate. Servers upgraded from before 2026-09-30 counted non-USD prices as USD. - Workaround: after upgrading, run `pnpm tsx scripts/backfill-usd.ts` (a dry run), then again with `--apply`, to recompute those rows. ## SDK features 6. **Paywalls, Customer Center, virtual currencies, targeting and experiments are not implemented.** The SDK endpoints answer empty results or 404, so the SDK hides these features instead of crashing. Paywalls built in RevenueCat do not render. - Workaround: build the paywall in your own UI code from the offerings. 7. **The stock Android SDK sends some traffic to RevenueCat.** Diagnostics, paywall events and ad events ignore the proxy URL. - Workaround: the RevenueDot Android fork fixes it, but it is not published yet. You can build it from the `revenuedot/main-patches` branch of [revenuedot/purchases-android](https://github.com/revenuedot/purchases-android). 8. **The stock web SDK sends analytics events to RevenueCat.** - Workaround: configure purchases-js with `flags: { collectAnalyticsEvents: false }`. 9. **Flutter web ignores the proxy URL with the stock SDK.** Flutter on iOS and Android works. - Workaround: the RevenueDot Flutter fork fixes it; use it as a git dependency on [revenuedot/purchases-flutter](https://github.com/revenuedot/purchases-flutter). 10. **The stock SDK reports signature verification FAILED.** RevenueDot cannot sign with RevenueCat's key. - Workaround: turn verification off, never use ENFORCED. See [signature verification](https://revenuedot.app/docs/help/signature-verification-failed.md). 11. **The SDK forks are not published to any registry.** npm, CocoaPods, Maven Central and OpenUPM releases need publishing credentials that are not set up yet. The forks' default host, `https://api.revenuedot.app`, is RevenueDot Cloud and is live. - Workaround: use the stock RevenueCat SDK with a proxy URL, or build a fork from its `revenuedot/main-patches` branch. ## Webhooks and events 12. **Six event types are never sent yet:** `TEMPORARY_ENTITLEMENT_GRANT`, `VIRTUAL_CURRENCY_TRANSACTION`, `INVOICE_ISSUANCE`, `EXPERIMENT_ENROLLMENT`, `PURCHASE_REDEEMED` and `SUBSCRIBER_ALIAS`. You can select them in filters. - Workaround: none needed unless your backend relies on them. 13. **Webhook payloads leave out `renewal_number`, `experiments` and `metadata`.** Every other field matches RevenueCat's sample payloads. - Workaround: count renewals in your backend from `RENEWAL` events. ## Migration 14. **Some RevenueCat data is not imported:** paywalls, targeting, experiments and virtual currency balances. Refunded subscriptions import as expired, because RevenueCat's API does not expose the refund. RevenueCat Billing renewals stay with RevenueCat. - Workaround: recreate paywalls in code, and keep RevenueCat running for RevenueCat Billing customers. 15. **Google purchase tokens are not in RevenueCat's API.** Imported Google subscriptions wait with the key `needs_token_refresh:` until a token arrives. - Workaround: pass `--google-tokens `, or let renewal notifications and one `syncPurchases()` in the app fill them in. `GET /v2/projects/{project_id}/import/status` counts what is left. ## Self-hosting 16. **Run one server container per database.** Expirations and webhooks are sent by a background job inside each container. - Workaround: scale up one container rather than out. There is no high-availability setup yet. 17. **There is no published Docker image.** Compose builds the image from source, which takes a few minutes on the first start. - Workaround: none needed. ## Related - [Frequently asked questions](https://revenuedot.app/docs/help/faq.md) - [Troubleshooting by symptom](https://revenuedot.app/docs/help/troubleshooting.md) - [What differs from RevenueCat](https://revenuedot.app/docs/migrate/what-differs.md) --- # Why is my entitlement not active? Source: https://revenuedot.app/docs/help/entitlement-not-active.md Description: Usually the product is not attached to the entitlement, the purchase belongs to another app user ID, the receipt post failed, or the access has ended. Here is how to tell which. An entitlement is active only when the customer has an unexpired purchase of a product that is **attached to that entitlement**. Most of the time one of four things is wrong: the product is not attached, the purchase landed on a different app user ID, the receipt post failed, or the access has ended. Check them in that order. ## Check what the server sees Ask for the customer the way the SDK does: ```bash curl -s -H "Authorization: Bearer $PUBLIC_KEY" \ https://revenuedot.example.com/v1/subscribers/user_1 ``` Read two parts of the answer: - `subscriber.subscriptions` and `subscriber.non_subscriptions` list what the customer bought. - `subscriber.entitlements` lists what that gives them. A purchase with no matching entitlement means the catalog is wrong. No purchase at all means the receipt never arrived or went to another customer. You can see the same on the dashboard's customer page, or with `GET /v2/projects/{project_id}/customers/{customer_id}/active_entitlements` and your secret key. ## Causes and fixes 1. **The product is not attached to the entitlement.** Attach it on the entitlement's dashboard page, or call `POST /v2/projects/{project_id}/entitlements/{entitlement_id}/actions/attach_products` with the product IDs. See [Products and entitlements](https://revenuedot.app/docs/concepts/products-and-entitlements.md). 2. **The product's store identifier does not match the store.** The product's `store_identifier` must equal the App Store product ID or the Google Play product ID exactly. A Google subscription matches either its subscription ID or `:`. Compare it with the `subscriptions` key in the customer info. 3. **Only one store's product is attached.** Products are created per app. If the Android product ID differs from the iOS one, an Android purchase unlocks nothing until you create the Android product and attach it too. 4. **The purchase belongs to another app user ID.** Purchases made before `logIn` sit on the anonymous ID (`$RCAnonymousID:...`) and move to the user on login. A restore can move a purchase away from another user, depending on the project's transfer behaviour. See [Anonymous app user IDs](https://revenuedot.app/docs/concepts/customers-and-app-user-ids.md#anonymous-app-user-ids) and [How do I restore purchases?](https://revenuedot.app/docs/help/restore-purchases.md) 5. **The receipt post failed.** A 5xx leaves the purchase on the device for a retry, so access appears only after the cause is fixed. A 4xx means RevenueDot refused the purchase for good. Check the server log and see [Why does RevenueDot answer 4xx or 5xx to a receipt?](https://revenuedot.app/docs/help/receipt-errors-4xx-vs-5xx.md) 6. **The access has ended.** Check `expires_date` in `subscriptions`. A refund sets `refunded_at` and ends access. A billing problem keeps access only while `grace_period_expires_date` is in the future. 7. **Store notifications are not arriving.** Renewals and cancellations after the first purchase reach RevenueDot through App Store Server Notifications and Google's real-time notifications. Without them, a renewed subscription can look expired. See [Why are store notifications not arriving?](https://revenuedot.app/docs/help/store-notifications-not-arriving.md) 8. **The app still shows old customer info.** The SDK caches customer info. Call `getCustomerInfo` again, or restart the app, after you fix the catalog. 9. **The SDK is not talking to your server.** If the proxy URL is set after `configure`, or not at all, the SDK asks RevenueCat instead. Set the proxy URL first. See the [SDK guides](https://revenuedot.app/docs/sdks.md). ## Access without a purchase To give a customer access by hand, for example after a support ticket, grant a promotional entitlement: ```bash curl -s -X POST https://revenuedot.example.com/v1/subscribers/user_1/entitlements/pro/promotional \ -H "Authorization: Bearer $SECRET_KEY" -H "Content-Type: application/json" \ -d '{"duration":"weekly"}' ``` The customer info then shows `pro` with the `PROMOTIONAL` store until it ends. See [REST API v1](https://revenuedot.app/docs/api/rest-v1.md). ## Related - [Products and entitlements](https://revenuedot.app/docs/concepts/products-and-entitlements.md) - [Offerings and packages](https://revenuedot.app/docs/concepts/offerings-and-packages.md) - [Troubleshooting by symptom](https://revenuedot.app/docs/help/troubleshooting.md) --- # What do I do if I forgot my RevenueDot password? Source: https://revenuedot.app/docs/help/forgot-password.md Description: Click Forgot password? on the sign-in page and follow the emailed link within 1 hour. Self-hosted servers without email print the link to the server log, or an admin resets the password from the command line. Click **Forgot password?** on the sign-in page, enter your email address and open the link RevenueDot emails you. The link works **once** and expires after **1 hour**. Choose a new password (at least 8 characters) and you are signed in. Setting it **signs you out on every other device** and makes older reset links stop working. ## What to expect - **The page always says the same thing:** "If an account uses this email, we sent it a link". It says this whether or not an account exists, so nobody can use the form to find out who has an account. - **The email comes from RevenueDot.** On RevenueDot Cloud the sender is `no-reply@mail.revenuedot.app`. Check your spam folder if it does not arrive within a few minutes. - **Limits:** 5 requests per network address per 15 minutes, then the page asks you to wait. At most 3 reset emails per address per hour; more requests show the same message but send nothing. - **An expired or used link** shows a message on the reset page. Ask for a new one. ## On a self-hosted server A self-hosted server sends email only when its admin set `REVENUEDOT_SMTP_URL`. Without it: 1. **The link is in the server log.** Request the reset, then run `docker compose logs revenuedot` and copy the link from the email printed there. See [Email](https://revenuedot.app/docs/guides/self-hosting.md#email). 2. **Or reset the password from the command line**, straight against the database: `revenuedot admin reset-password `. See [Reset a password without email](https://revenuedot.app/docs/guides/self-hosting.md#reset-a-password-without-email). ## Related - [Invite your team](https://revenuedot.app/docs/guides/team.md) - [Self-hosting](https://revenuedot.app/docs/guides/self-hosting.md) - [Password reset endpoints](https://revenuedot.app/docs/api/extensions.md#email-a-password-reset-link) --- # Why does RevenueDot answer 4xx or 5xx to a receipt? Source: https://revenuedot.app/docs/help/receipt-errors-4xx-vs-5xx.md Description: A 4xx tells the SDK the purchase is permanently bad, so it finishes the transaction for good. A 5xx means try again later, so the SDK keeps the transaction and retries. The HTTP status tells the SDK what to do with the store transaction. A **4xx** means the purchase can never be accepted, so the SDK finishes (or, on Android, stops retrying) the transaction for good. A **5xx** means RevenueDot or the store had a temporary problem, so the SDK keeps the transaction open and posts it again later. RevenueDot never answers 4xx for its own failures, so a server bug cannot make a paying customer lose a purchase. The SDK side of this rule is in the SDKs' own source. In purchases-ios, a failed post is "finishable" only when the status is not a server error ([NetworkError.swift](https://github.com/RevenueCat/purchases-ios/blob/main/Sources/Networking/HTTPClient/NetworkError.swift)). purchases-android makes the same split with `isServerError` ([Backend.kt](https://github.com/RevenueCat/purchases-android/blob/main/purchases/src/main/kotlin/com/revenuecat/purchases/common/Backend.kt)). ## What every error looks like Every error has a JSON body with a numeric `code` and a `message`: ```bash curl -s -w '\n%{http_code}\n' http://localhost:8787/v1/receipts \ -H "Authorization: Bearer $TEST_KEY" -H "Content-Type: application/json" \ -d '{"app_user_id":"user_1","fetch_token":"not-a-token","product_id":"pro_monthly"}' ``` ```text {"code":7103,"message":"The receipt is not a valid Test Store purchase token."} 400 ``` ## The codes, and where each one comes from The codes are defined in [`apps/server/src/errors.ts`](https://github.com/revenuedot/revenuedot/blob/main/apps/server/src/errors.ts). They are the numbers the RevenueCat SDKs already understand. ### Permanent: 4xx, the SDK finishes the transaction | HTTP | Code | Meaning | Thrown in | When | |---|---|---|---|---| | 401 | 7225 | Invalid API key | `routes/sdk.ts` | The `Authorization: Bearer` key is missing or unknown. Every SDK call answers this, not only receipts | | 400 | 7220 | Invalid app user ID | `routes/sdk.ts` | `app_user_id` is empty or longer than 100 characters | | 400 | 7000 | Bad request | `routes/sdk.ts` | A receipt posted with a secret key (`sk_`) has no `X-Platform` header, so RevenueDot cannot tell which app it is for | | 400 | 7662 | Receipts for this store are not supported yet | `routes/sdk.ts` | The key belongs to an Amazon, Stripe, Web Billing, Paddle or Roku app | | 400 | 7103 | Invalid receipt | `routes/sdk.ts` | The body has neither `fetch_token` nor `app_transaction` | | 400 | 7103 | Invalid receipt | `stores/test-store.ts` | The Test Store token is not `test__`, or `product_id` is missing | | 400 | 7103 | Invalid receipt | `stores/apple/index.ts` | The StoreKit 2 transaction's signature does not verify, the bundle ID does not match the app, the app receipt cannot be parsed, or it is an Xcode receipt without the `xcode_certificate` credential | | 400 | 7103 | Invalid receipt | `stores/google/index.ts`, `stores/google/api.ts` | The purchase token is missing or Google says it is not valid, `product_ids` is missing for a one-time product, or a pending purchase was cancelled | | 400 | 7102 | Receipt already in use | `services/purchases.ts` | Another known user owns this purchase and the project's transfer behaviour is `keep`, or `transfer_if_no_active` while that user still has an active subscription | ### Temporary: 5xx, the SDK retries | HTTP | Code | Meaning | Thrown in | When | |---|---|---|---|---| | 500 | 7234 | App Store in-app purchase key problem | `stores/apple/index.ts` | A StoreKit 1 receipt arrived and the app has no in-app purchase key. The SDK retries after you add the key | | 500 | 7234 | App Store in-app purchase key problem | `stores/apple/api.ts` | The key is incomplete, the private key is not a valid `.p8`, or Apple rejected it with 401 | | 503 | 7101 | Store problem | `stores/apple/api.ts`, `stores/apple/index.ts` | Apple could not be reached, answered 429 or 5xx, or returned something RevenueDot could not verify | | 503 | 7101 | Store problem | `stores/google/index.ts`, `stores/google/api.ts` | The Google purchase is still pending payment, the service account is missing or rejected, the package name is wrong, or Google failed | | 500 | 7110 | Internal error | `errors.ts` | Anything unexpected inside RevenueDot, such as a database error | A wrong App Store key or Google service account is your setup problem, not the customer's. That is why it answers 5xx: the purchase stays on the device until you fix the credentials. ## What to do 1. **Read the `message`.** It names the cause, for example the bundle ID it expected. 2. **For 7225,** check that the SDK uses the public key of an app in this project. See [Which key goes where](https://revenuedot.app/docs/concepts/projects-and-apps.md#which-key-goes-where). 3. **For 7103 from the App Store,** check the app's `bundle_id` matches the build you are testing. For Xcode StoreKit testing, add the `xcode_certificate` credential. See [How do I test purchases without real money?](https://revenuedot.app/docs/help/test-sandbox-purchases.md) 4. **For 7103 from Google,** check the app's `package_name`, and that the purchase was made by this app. 5. **For 7102,** decide who should own restored purchases and set `transfer_behavior`. See [How do I restore purchases?](https://revenuedot.app/docs/help/restore-purchases.md) 6. **For 7662,** use an App Store, Mac App Store, Google Play or Test Store app. Other stores are not supported yet. 7. **For 7234,** add the App Store in-app purchase key to the app. See [Connect the App Store](https://revenuedot.app/docs/guides/app-store.md). For local development only, you can set `allow_unsigned_receipts`. 8. **For 7101 from Google,** run **Verify credentials** on the app page, or `POST /v2/projects/{project_id}/apps/{app_id}/actions/verify_credentials`, and grant the service account access in Play Console. See [Connect Google Play](https://revenuedot.app/docs/guides/google-play.md). 9. **For 7110,** read the server log. The error is printed there with its stack. Open an issue if it looks like a bug. After you fix a 5xx cause, you do not need to change the app. The transaction is still unfinished on the device, so the SDK posts it again the next time it retries pending transactions. ## Related - [Why is my entitlement not active?](https://revenuedot.app/docs/help/entitlement-not-active.md) - [Troubleshooting by symptom](https://revenuedot.app/docs/help/troubleshooting.md) - [API errors](https://revenuedot.app/docs/api/errors.md) - [SDK endpoints](https://revenuedot.app/docs/api/sdk-endpoints.md) --- # How do I restore purchases? Source: https://revenuedot.app/docs/help/restore-purchases.md Description: Call restorePurchases from a Restore button, or syncPurchases silently. The project's transfer behaviour decides who owns a purchase another user already has. Call the SDK's `restorePurchases()` from a **Restore purchases** button, the same code you use with RevenueCat. The SDK posts the device's store receipts to `POST /v1/receipts`, and RevenueDot answers with the updated customer info. If the purchase already belongs to another user, the project's **transfer behaviour** decides what happens: by default the purchase moves to the user who restored it, and RevenueDot sends a `TRANSFER` webhook. ## Restore or sync | Call | When to use it | What the user sees | |---|---|---| | `restorePurchases()` | The user taps **Restore purchases** | On iOS, it may ask the user to sign in to their Apple account | | `syncPurchases()` | Silently, for example once after a migration, so RevenueDot learns about purchases it has not seen | Nothing | ```swift // iOS let customerInfo = try await Purchases.shared.restorePurchases() ``` ```kotlin // Android Purchases.sharedInstance.restorePurchasesWith( onError = { error -> /* show error.message */ }, onSuccess = { customerInfo -> /* check customerInfo.entitlements */ }, ) ``` ```ts // React Native const customerInfo = await Purchases.restorePurchases(); ``` RevenueCat recommends calling restore only from a button, not on every launch ([RevenueCat docs](https://www.revenuecat.com/docs/getting-started/restoring-purchases)). The same advice applies here. ## Who owns a restored purchase RevenueDot follows the project setting `transfer_behavior`. The four values match RevenueCat's options ([RevenueCat docs](https://www.revenuecat.com/docs/projects/restore-behavior)). | `transfer_behavior` | When another known user already owns the purchase | |---|---| | `transfer` (default) | The purchase moves to the user who restored it. RevenueDot sends a `TRANSFER` event | | `transfer_if_no_active` | The purchase moves only if the current owner has no active subscription. Otherwise the restore fails with 7102 | | `keep` | The purchase stays with its owner. The restore fails with HTTP 400, code 7102, "The receipt is already in use by another subscriber." | | `share` | The two users are merged into one customer, so both IDs share the purchase | Two cases never depend on the setting: - If the current owner is **anonymous** (`$RCAnonymousID:...`), it is always merged into the user who restored. - If the user restoring is **anonymous** and the owner is a known user, the anonymous ID is merged into the owner. Change the setting on the dashboard's project settings, or with the API: ```bash curl -s -X POST https://revenuedot.example.com/v2/projects/$PROJECT_ID \ -H "Authorization: Bearer $SECRET_KEY" -H "Content-Type: application/json" \ -d '{"transfer_behavior":"transfer_if_no_active","sandbox_transfer_behavior":"transfer"}' ``` `sandbox_transfer_behavior` applies to sandbox purchases. Leave it `null` to use `transfer_behavior` for both. More background is in [Who owns a restored purchase](https://revenuedot.app/docs/concepts/customers-and-app-user-ids.md#who-owns-a-restored-purchase). ## The TRANSFER webhook When a subscription moves, your webhook receives an event like this one, captured from a local run: ```json { "api_version": "1.0", "event": { "id": "90CB2D5B-DDD2-4F25-9430-4E5290CC493A", "type": "TRANSFER", "store": "TEST_STORE", "app_id": "appvnrm0a5h", "environment": "SANDBOX", "transferred_from": ["alice"], "transferred_to": ["bob"], "event_timestamp_ms": 1790800924235, "subscriber_attributes": {} } } ``` Use it to move access in your own database from the `transferred_from` IDs to the `transferred_to` IDs. ## If the restore fails with 7102 1. The project uses `keep`, or `transfer_if_no_active` while the owner still has an active subscription. 2. Tell the user the purchase belongs to another account, and ask them to sign in with that account. 3. If the rule is too strict for your app, switch to `transfer`. ## If the restore finds nothing 1. Check that the user is signed in to the same Apple or Google account that bought. 2. Check that the purchase is for this app's bundle ID or package name. 3. Check the server log for a 5xx on `/v1/receipts`. See [Why does RevenueDot answer 4xx or 5xx to a receipt?](https://revenuedot.app/docs/help/receipt-errors-4xx-vs-5xx.md) ## Related - [Customers and app user IDs](https://revenuedot.app/docs/concepts/customers-and-app-user-ids.md) - [Why is my entitlement not active?](https://revenuedot.app/docs/help/entitlement-not-active.md) - [Webhook events](https://revenuedot.app/docs/api/webhook-events.md) --- # Why does the SDK report signature verification FAILED in proxy mode? Source: https://revenuedot.app/docs/help/signature-verification-failed.md Description: The stock RevenueCat SDK checks responses against RevenueCat's signing key, which RevenueDot cannot use. Turn verification off, or build the SDK forks with your own key. The stock RevenueCat SDK trusts only RevenueCat's response-signing key, and RevenueDot cannot sign with it. So when entitlement verification is on, every RevenueDot response reads as `FAILED`. Access is still granted in the default informational mode, and nothing breaks. The fix is to turn verification off in the app, or to use the RevenueDot SDK forks built with your own server's key. **Never use ENFORCED mode with the stock SDK against RevenueDot**: it would reject every response. RevenueCat calls this feature Trusted Entitlements. Its modes are disabled, informational and enforced ([RevenueCat docs](https://www.revenuecat.com/docs/customers/trusted-entitlements)). ## What each SDK does by default | SDK | Default mode | What you see | Fix with the stock SDK | |---|---|---|---| | iOS, Android (native) | Informational | A logged verification failure; access is still granted; `EntitlementInfos.verification` is `FAILED` | Set the mode to disabled | | Unity | Informational | Same as native | Set **Entitlement Verification Mode** to **Disabled** on the Purchases component in the Inspector | | Capacitor | None passed, so the native default applies (informational) | Same as native | Pass `ENTITLEMENT_VERIFICATION_MODE.DISABLED` to `configure` | | React Native, Flutter, Kotlin Multiplatform | Disabled | Nothing | Nothing to do | | Cordova | No option | A logged verification failure; access is still granted | Nothing you can change; ignore the log line | | Web (purchases-js) | Does not verify | Nothing | Nothing to do | ## Turn verification off ```swift // iOS: Point the SDK at your RevenueDot server; nothing else in the app changes. Purchases.proxyURL = URL(string: "https://revenuedot.example.com")! Purchases.configure(with: Configuration.Builder(withAPIKey: "appl_...") .with(entitlementVerificationMode: .disabled) .build()) ``` ```kotlin // Android: Point the SDK at your RevenueDot server; nothing else in the app changes. Purchases.proxyURL = URL("https://revenuedot.example.com") Purchases.configure( PurchasesConfiguration.Builder(context, "goog_...") .entitlementVerificationMode(EntitlementVerificationMode.DISABLED) .build() ) ``` ```ts // Capacitor: Point the SDK at your RevenueDot server; nothing else in the app changes. await Purchases.setProxyURL({ url: "https://revenuedot.example.com" }); await Purchases.configure({ apiKey: "appl_...", entitlementVerificationMode: ENTITLEMENT_VERIFICATION_MODE.DISABLED }); ``` On React Native, Flutter and Kotlin Multiplatform, leave the mode at its default. If your code sets it to informational, remove that line. ## Get VERIFIED instead: sign with your own key RevenueDot can sign responses exactly the way the SDKs check them. It signs every 2xx and 3xx response under `/v1` and `/rcbilling` when `REVENUEDOT_SIGNING_KEY` is set. For your app to accept those signatures, the SDK must carry your public key, which means building the forks. 1. **Make a key pair** in a checkout of the server repo: ```bash pnpm tsx scripts/signing-keygen.ts ``` ```text REVENUEDOT_SIGNING_KEY= public key: ``` 2. **Give the server the private seed** as the `REVENUEDOT_SIGNING_KEY` environment variable, and restart it. Keep it out of every repository. 3. **Check the public key** the server now serves: ```bash curl -s https://revenuedot.example.com/.well-known/revenuedot-signing-key ``` ```json {"algorithm":"Ed25519","public_key":"ZzwPxGlon0E8ErpDh9QAH0Jh6+E6D6qufvTSetXZY9Y=","encoding":"base64","header":"X-Signature","docs":"https://revenuedot.app/docs"} ``` Without a key it answers 404 with "Response signing is not configured on this server." 4. **Build the forks with your host and key:** ```bash pnpm tsx scripts/forks/apply.ts --var apiHost=https://revenuedot.example.com --var signingPublicKey= ``` You can also set `REVENUEDOT_FORK_API_HOST` and `REVENUEDOT_FORK_SIGNING_PUBLIC_KEY`. Then build the fork for your platform and ship it in your app. 5. **Turn verification back on** (informational) in the app, and check that `verification` reads `VERIFIED`. The official RevenueDot forks trust RevenueDot Cloud's key (`gXdn2hmqR/TbdtQwK02laE0YgFz0Rtf918LICLrgZhg=`). A self-hosted server cannot sign with that key, so the official builds still report `FAILED` against your server. The forks are not published to any registry yet as of 2026-09-30. Full details are in [Trusted Entitlements](https://revenuedot.app/docs/guides/trusted-entitlements.md). ## Related - [Trusted Entitlements](https://revenuedot.app/docs/guides/trusted-entitlements.md) - [Connect your app](https://revenuedot.app/docs/getting-started/connect-your-app.md) - [SDK changes when migrating](https://revenuedot.app/docs/migrate/sdk-changes.md) - [Blog: Why we forked the RevenueCat SDKs](https://revenuedot.app/blog/why-we-forked-the-revenuecat-sdks.md) --- # Why are store notifications not arriving? Source: https://revenuedot.app/docs/help/store-notifications-not-arriving.md Description: Check notification_status in setup health. Most failures are a wrong URL, a bundle ID or package name mismatch, Pub/Sub push auth, or purchases RevenueDot has never seen. Start with the app's `notification_status` in setup health. It tells you whether nothing has arrived (`waiting`), something arrived but none of it counted (`received`), the newest one failed (`failing`), or all is well (`ready`). The usual causes are a wrong URL in App Store Connect or Pub/Sub, a bundle ID or package name that does not match the app, a Pub/Sub push token RevenueDot rejects, or notifications about purchases RevenueDot has never seen. ## Read the status ```bash curl -s -H "Authorization: Bearer $SECRET_KEY" \ https://revenuedot.example.com/v2/projects/$PROJECT_ID/setup_health ``` Each app in `apps` carries these fields. The dashboard shows the same on the app page. | Field | Meaning | |---|---| | `notification_url` | The URL to give the store: `/v1/notifications/apple/{app_id}` or `/v1/notifications/google/{app_id}` | | `notification_status` | `ready`, `failing`, `received` or `waiting` (below) | | `last_notification_at` | The last time a notification was processed for a purchase RevenueDot knows, or was the store's test message | | `last_notification_received_at` | The last time anything arrived | | `last_notification_error` | The newest failure: `at`, `type` and `message` | | `notification_status` | What it means | What to do | |---|---|---| | `waiting` | Nothing has ever arrived for this app | Check the URL in the store console, and that the server is reachable from the internet over HTTPS | | `received` | Notifications arrive, but none was for a purchase RevenueDot knows | Normal before the first purchase. Send the store's test notification, or turn on `track_new_purchases` (below) | | `failing` | The newest notification failed, and no later one succeeded | Read `last_notification_error.message` | | `ready` | A notification was processed for a known purchase, or was the store's test message | Nothing | The logic is in [`notification-health.ts`](https://github.com/revenuedot/revenuedot/blob/main/apps/server/src/routes/v2/notification-health.ts). ## Send a test notification - **App Store:** use **Request a Test Notification** from the App Store Server API ([Apple docs](https://developer.apple.com/documentation/appstoreserverapi/request-a-test-notification)). A valid test turns the app `ready`. - **Google Play:** click **Send Test Message** where you set up real-time developer notifications in Play Console ([Google docs](https://developer.android.com/google/play/billing/getting-ready)). The test message turns the app `ready`. ## Causes and fixes 1. **The URL is wrong or unreachable.** Copy `notification_url` exactly. It must be the public HTTPS address of your server. Behind a proxy, RevenueDot builds it from `X-Forwarded-Host` and `X-Forwarded-Proto`. 2. **App Store Connect only has the production URL.** Sandbox purchases need the same URL in the **Sandbox Server URL** field, and the version must be **Version 2** notifications. See [Connect the App Store](https://revenuedot.app/docs/guides/app-store.md). 3. **The bundle ID does not match.** RevenueDot answers 400 and records "The notification is for bundle id X, not Y." Fix `bundle_id` on the app, or point this bundle's notifications at the right app ID. 4. **The Apple app ID does not match.** If the app has an `app_apple_id` credential, production notifications for another Apple app ID are refused with 400. 5. **The package name does not match.** RevenueDot answers 200 so Pub/Sub stops redelivering, and records "package X does not match the app's Y". The status turns `failing`. Fix `package_name`, or use a separate topic per app. 6. **Pub/Sub push authentication fails.** If the app has the `pubsub_audience` credential, every push must carry a Google-signed token for that audience, and, when `pubsub_service_account` is set, from that service account. A missing or wrong token gets HTTP 401 with code 7224. Turn on authentication on the push subscription with the same audience and service account ([Google docs](https://cloud.google.com/pubsub/docs/authenticate-push-subscriptions)), or remove `pubsub_audience`. 7. **The purchases are unknown to RevenueDot.** A notification for a purchase no app has posted to `/v1/receipts` is stored but not applied, and the status stays `received`. This is normal in a dual run until users open the app. To create those purchases from notifications, set `track_new_purchases`: ```bash curl -s -X POST https://revenuedot.example.com/v2/projects/$PROJECT_ID/apps/$APP_ID \ -H "Authorization: Bearer $SECRET_KEY" -H "Content-Type: application/json" \ -d '{"app_store":{"track_new_purchases":true}}' ``` Use `play_store` instead of `app_store` for a Google Play app. For the App Store, the purchase lands on the customer named by the transaction's `appAccountToken` when it matches one. Otherwise it lands on a new anonymous customer until the app posts it. 8. **Google cannot be reached with the service account.** Google notifications only carry a purchase token, so RevenueDot reads the purchase from the Play Developer API. A missing or rejected service account makes each notification fail with 500 and Pub/Sub retries it. See [Connect Google Play](https://revenuedot.app/docs/guides/google-play.md). ## What RevenueDot answers, and why The stores redeliver anything that is not a 2xx. RevenueDot answers so that a store retries only what could succeed later. | Store | Answer | When | |---|---|---| | App Store | 200 `{"ok":true}` | The payload verified, including notifications for purchases RevenueDot does not track | | App Store | 400 | The payload cannot be verified or belongs to another app: not JSON, no `signedPayload`, a bad signature, another bundle ID or Apple app ID | | App Store | 404 | No App Store app has this ID | | App Store | 500 | RevenueDot itself failed; Apple retries | | Google Play | 200 with `status` | `processed`, `unknown_purchase`, `ignored` (unreadable data or another package), `invalid_token` or `duplicate` | | Google Play | 400, code 7000 | The body is not a Pub/Sub push message | | Google Play | 401, code 7224 | The Pub/Sub push token is missing or invalid | | Google Play | 404 | No Google Play app has this ID | | Google Play | 500 or 503 | A temporary failure at RevenueDot or Google; Pub/Sub retries | Every notification is stored before it is processed, so you can see it even when it failed. During a dual run, RevenueDot also copies the exact body to the app's `notification_forward_url`. `last_forward` in `GET /v2/projects/{project_id}/apps/{app_id}/store_settings` shows the last forward's HTTP status, where 0 means no answer within 10 seconds. See [Dual run](https://revenuedot.app/docs/migrate/dual-run.md). ## Related - [Connect the App Store](https://revenuedot.app/docs/guides/app-store.md) - [Connect Google Play](https://revenuedot.app/docs/guides/google-play.md) - [Why is my entitlement not active?](https://revenuedot.app/docs/help/entitlement-not-active.md) --- # How do I test purchases without real money? Source: https://revenuedot.app/docs/help/test-sandbox-purchases.md Description: Use the Test Store for the fastest loop, then App Store sandbox, StoreKit testing in Xcode with the xcode_certificate credential, or Google Play license testers. Start with the **Test Store**: it needs no App Store or Google Play account and works in seconds. Then test with each store's own sandbox: **App Store sandbox** accounts, **StoreKit testing in Xcode** (after you give RevenueDot Xcode's certificate), and **Google Play license testers**. RevenueDot marks all of these purchases as sandbox, so they stay out of your production numbers and webhooks carry `"environment": "SANDBOX"`. > App Store and Google Play support is tested against mocked Apple and Google APIs only. No real sandbox purchase has run end to end yet (2026-09-30). Please report what you find. ## Test Store: no store account needed 1. Create an app with `type: test_store`, or run the [seed script](https://github.com/revenuedot/examples/blob/main/selfhost/docker-compose/seed.sh). 2. Use its `test_` key as the SDK's API key and your server as the proxy URL. 3. Buy. The SDK shows a Test Store dialog and posts a `fetch_token` of the form `test__`. You can also post a purchase yourself: ```bash curl -s http://localhost:8787/v1/receipts \ -H "Authorization: Bearer $TEST_KEY" -H "Content-Type: application/json" \ -d "{\"app_user_id\":\"user_1\",\"fetch_token\":\"test_$(date +%s)000_demo\",\"product_id\":\"pro_monthly\",\"price\":9.99,\"currency\":\"USD\"}" ``` To test what happens later in a subscription's life, simulate it with your secret key. `scenario` is one of `purchase`, `trial`, `trial_conversion`, `renewal`, `cancel`, `billing_issue`, `refund` or `expire`: ```bash curl -s -X POST http://localhost:8787/v2/projects/$PROJECT_ID/test_purchases \ -H "Authorization: Bearer $SECRET_KEY" -H "Content-Type: application/json" \ -d '{"app_user_id":"user_refund","product_id":"pro_monthly","scenario":"refund"}' ``` The answer lists the events it produced, here `"event_types":["INITIAL_PURCHASE","CANCELLATION"]`, and your webhooks receive them. See [Test Store](https://revenuedot.app/docs/guides/test-store.md). Test Store runs work with the native SDKs too: the unmodified RevenueCat iOS SDK 5.92 on the iPhone simulator and Android SDK 10.24 on the Android emulator buy through the SDK's Test Store dialog against RevenueDot. purchases-js and React Native (Expo Go or web) work as well. ## App Store sandbox 1. Connect the app: set its `bundle_id` and add the App Store in-app purchase key. See [Connect the App Store](https://revenuedot.app/docs/guides/app-store.md). 2. Create a sandbox Apple account in App Store Connect ([Apple docs](https://developer.apple.com/documentation/storekit/testing-in-app-purchases-with-sandbox)). 3. Run a development or TestFlight build and buy with that account. Sandbox transactions are signed by Apple, so RevenueDot verifies them like production ones and stores them with `is_sandbox: true`. Sandbox subscriptions renew on Apple's shortened schedule, and renewals reach RevenueDot through App Store Server Notifications. Put the notification URL in App Store Connect's **Sandbox Server URL** too. See [Why are store notifications not arriving?](https://revenuedot.app/docs/help/store-notifications-not-arriving.md) ## StoreKit testing in Xcode Xcode signs local test transactions with its own certificate, not Apple's ([Apple docs](https://developer.apple.com/documentation/xcode/setting-up-storekit-testing-in-xcode)). RevenueDot refuses them with code 7103 until you give it that certificate: 1. Open your `.storekit` configuration file in Xcode and choose **Editor → Save Public Certificate**. 2. Store the certificate on the app as the `xcode_certificate` credential. PEM text or base64 DER both work: ```bash CERT=$(base64 < StoreKitTestCertificate.cer | tr -d '\n') curl -s -X POST http://localhost:8787/v2/projects/$PROJECT_ID/apps/$APP_ID \ -H "Authorization: Bearer $SECRET_KEY" -H "Content-Type: application/json" \ -d "{\"app_store\":{\"xcode_certificate\":\"$CERT\"}}" ``` 3. Buy in the simulator. RevenueDot now accepts the local transactions. Use this only on a development server. Anyone with the certificate can sign transactions it will accept. ## Google Play license testers 1. Connect the app: set its `package_name` and add the service account. See [Connect Google Play](https://revenuedot.app/docs/guides/google-play.md). 2. Add tester accounts under **License testing** in Play Console, and publish the app to an internal testing track ([Google docs](https://developer.android.com/google/play/billing/test)). 3. Install from the track and buy with a tester account. Google uses test cards and shortened renewal periods. RevenueDot reads the purchase from the Play Developer API and acknowledges it. Google tells RevenueDot it is a test purchase, so it is stored as sandbox. ## Keep test purchases apart - Set `sandbox_transfer_behavior` on the project if testers share devices and you want restores to behave differently in sandbox. See [How do I restore purchases?](https://revenuedot.app/docs/help/restore-purchases.md) - Point a webhook at a staging backend with `environment: "sandbox"`, and your production webhook at `environment: "production"`. See [Webhooks](https://revenuedot.app/docs/guides/webhooks.md). ## Related - [Sandbox](https://revenuedot.app/docs/concepts/sandbox.md) - [Sandbox testing guide](https://revenuedot.app/docs/guides/sandbox-testing.md) - [Test Store](https://revenuedot.app/docs/guides/test-store.md) --- # Why are webhooks not arriving? Source: https://revenuedot.app/docs/help/webhooks-not-arriving.md Description: Check the webhook's delivery log first. Most misses are a filter that excludes the event, a backend that answers something other than 200, or a URL the server cannot reach. Open the webhook's delivery log first: it shows every attempt with its HTTP status and error. No delivery at all means a filter excluded the event, or the webhook did not exist yet when the event happened. Deliveries marked `pending` or `failed` mean your backend answered something other than **HTTP 200**, timed out after 60 seconds, or could not be reached. ## Look at the deliveries ```bash curl -s -H "Authorization: Bearer $SECRET_KEY" \ "https://revenuedot.example.com/v2/projects/$PROJECT_ID/webhooks/$WEBHOOK_ID/deliveries?status=failed" ``` The dashboard's **Webhooks** page shows the same log. For a project-wide view, `GET /v2/projects/{project_id}/setup_health` has a `webhooks` block with the delivered share over the last 24 hours and every webhook whose last attempt failed. To check the wiring end to end, send a `TEST` event: ```bash curl -s -X POST -H "Authorization: Bearer $SECRET_KEY" \ https://revenuedot.example.com/v2/projects/$PROJECT_ID/integrations/webhooks/$WEBHOOK_ID/test ``` ## No delivery at all 1. **The environment filter excludes it.** A webhook with `environment: "production"` never receives sandbox or Test Store events. Test Store purchases are always sandbox. Set `environment` to `null` for both. 2. **The event type filter excludes it.** If `event_types` is set, only those types are sent. 3. **The app filter excludes it.** If `app_id` is set, events from other apps are skipped. 4. **The webhook did not exist yet.** Deliveries are queued when the event happens, for the webhooks that exist then. A webhook you add later does not get older events. 5. **No event happened.** An imported customer produces no events unless the import ran with `--emit-events`. A purchase RevenueDot has not seen, reported only by a store notification, produces nothing unless `track_new_purchases` is on. See [Why are store notifications not arriving?](https://revenuedot.app/docs/help/store-notifications-not-arriving.md) 6. **The event type is never sent yet.** RevenueDot accepts `TEMPORARY_ENTITLEMENT_GRANT`, `VIRTUAL_CURRENCY_TRANSACTION`, `INVOICE_ISSUANCE`, `EXPERIMENT_ENROLLMENT`, `PURCHASE_REDEEMED` and `SUBSCRIBER_ALIAS` in filters, but does not produce them yet. See [Known issues](https://revenuedot.app/docs/help/known-issues.md). ## Deliveries that fail 1. **Your backend answers something other than 200.** Only 200 counts as delivered. A 201, 204 or redirect is a failure and is retried. Answer 200 as soon as you have stored the event, and do slow work afterwards. 2. **Your backend is too slow.** RevenueDot waits 60 seconds, then counts the attempt as failed. 3. **Signature checks reject the request.** Verify `X-RevenueCat-Webhook-Signature` against the raw request body, not re-serialized JSON, with the `whsec_` secret of this webhook. The header is `t=,v1=.">`. A new signature is made for every attempt. See [Webhooks](https://revenuedot.app/docs/guides/webhooks.md) for verification code. 4. **The `Authorization` header does not match.** If you set `authorization_header` on the webhook, RevenueDot sends it exactly as stored. Compare it with what your backend expects. 5. **The server cannot reach the URL.** A self-hosted container cannot reach `localhost` on your computer. Use `http://host.docker.internal:` on Mac and Windows, and on Linux add `extra_hosts: ["host.docker.internal:host-gateway"]` to the service. ## Retries and timing - The server sends due webhooks from a background job every 30 seconds, and right after a purchase. - A failed attempt is retried after 5, 10, 20, 40 and 80 minutes. After that the delivery is marked `failed`. This matches RevenueCat's schedule ([RevenueCat docs](https://www.revenuecat.com/docs/integrations/webhooks)). - Retry by hand from the dashboard, or with `POST /v2/projects/{project_id}/webhooks/{webhook_id}/deliveries/{delivery_id}/retry`. - Delivery is at least once. Deduplicate on `event.id`. A real delivery, captured from a local run, looks like this: ```text POST /api/webhooks/revenuedot content-type: application/json user-agent: RevenueDot-Webhooks/1.0 x-revenuecat-webhook-signature: t=1790800914,v1=5e6c0809f9b7b24f7ae36f4744b3b04868222411db4b24c60e6e390c5e9c495f {"event":{"id":"E6BD2240-52BB-444A-8C96-AA85468119C6","type":"INITIAL_PURCHASE","store":"PROMOTIONAL","app_user_id":"user_9","environment":"PRODUCTION","product_id":"rc_promo_pro_weekly","period_type":"PROMOTIONAL", "...": "..."},"api_version":"1.0"} ``` ## Related - [Webhooks](https://revenuedot.app/docs/guides/webhooks.md) - [Webhook events](https://revenuedot.app/docs/api/webhook-events.md) - [Troubleshooting by symptom](https://revenuedot.app/docs/help/troubleshooting.md) === Blog === # RevenueDot blog Source: https://revenuedot.app/blog.md Description: Posts from the RevenueDot team about building an open-source, self-hostable backend for in-app purchases that works with the RevenueCat SDK. Posts from the team building RevenueDot, an open-source (AGPL-3.0), self-hostable backend for in-app purchases and subscriptions that works with the RevenueCat SDK. Newest first. ## 2026-09-30 - [Introducing RevenueDot](https://revenuedot.app/blog/introducing-revenuedot.md): what we are building, why, and what it does today. - [How RevenueCat compatibility works](https://revenuedot.app/blog/how-revenuecat-compatible-works.md): the SDK endpoints, the error-code rule that protects purchases, the contract tests, and response signing byte by byte. - [Migrating from RevenueCat without losing a subscriber](https://revenuedot.app/blog/migrating-from-revenuecat-without-data-loss.md): import, keep your public keys, run both side by side, verify, then switch. - [Self-host RevenueDot in 5 minutes](https://revenuedot.app/blog/self-host-revenuedot-in-5-minutes.md): Docker Compose, a seeded project, a first Test Store purchase with `curl` and a webhook to your laptop. - [Why we forked the RevenueCat SDKs](https://revenuedot.app/blog/why-we-forked-the-revenuecat-sdks.md): ten MIT forks, what the patches change, why import names stay the same, and how the forks keep up with upstream. --- # How RevenueDot stays compatible with the RevenueCat SDK Source: https://revenuedot.app/blog/how-revenuecat-compatible-works.md Description: The endpoints the SDK calls, the customer info it decodes, the 4xx/5xx rule that protects purchases, the contract tests, and response signing byte by byte. RevenueDot works with the RevenueCat SDK because it answers the SDK's HTTP calls with the same paths, the same JSON, the same headers and the same error codes the SDK expects. We do not guess that shape: the tests run the request and response samples from the SDKs' own test suites against our server, and a change that breaks one does not merge. This post walks through the contract, from the endpoints to the signature bytes, and ends with what proxy mode cannot fix. ## The SDK talks to a handful of endpoints Every RevenueCat SDK accepts a proxy URL and sends its API calls there instead of RevenueCat's host. RevenueDot serves those calls. The ones that matter most: | Endpoint | What the SDK uses it for | |---|---| | `GET /v1/subscribers/{app_user_id}` | Customer info: active entitlements, subscriptions, one-time purchases | | `GET /v1/subscribers/{app_user_id}/offerings` | The offerings and packages the paywall shows | | `POST /v1/receipts` | Every purchase, restore and sync | | `POST /v1/subscribers/identify` | `logIn`: 201 when the user is new, 200 when it existed | | `POST /v1/subscribers/{app_user_id}/attributes` | Customer attributes such as `$email` | | `GET /v1/product_entitlement_mapping` | Offline entitlements when the server is unreachable | Every call carries `Authorization: Bearer `. The key prefix, such as `appl_`, `goog_` or `test_`, is the same as RevenueCat's, so SDK checks on the prefix pass. RevenueDot also serves the smaller calls the SDK makes on start, such as remote configuration, events and diagnostics. When a feature is not built, the answer is shaped so the SDK hides it. Customer Center answers 404, virtual currencies answer an empty list, and remote configuration answers 204. ## Customer info is the heart of the contract The SDK decodes one JSON document into `CustomerInfo`. Here is a real one from a local server, after a Test Store purchase, shortened: ```json { "request_date": "2026-09-30T20:41:54Z", "request_date_ms": 1790800914034, "subscriber": { "entitlements": { "pro": { "expires_date": "2026-10-30T20:41:54Z", "grace_period_expires_date": null, "product_identifier": "pro_monthly", "purchase_date": "2026-09-30T20:41:54Z" } }, "first_seen": "2026-09-30T20:41:54Z", "management_url": null, "non_subscriptions": {}, "original_app_user_id": "user_1", "subscriptions": { "pro_monthly": { "expires_date": "2026-10-30T20:41:54Z", "is_sandbox": true, "ownership_type": "PURCHASED", "period_type": "normal", "store": "test_store", "store_transaction_id": "test_1790800914000_quickstart", "unsubscribe_detected_at": null, "price": { "amount": 9.99, "currency": "USD" } } } }, "purchased_products": { "pro_monthly": { "should_consume": false } } } ``` Two details show how exact this has to be. `purchased_products` tells Android whether to consume a one-time purchase: get it wrong and a consumable can be bought only once. And iOS matches a one-time purchase to its transaction through `store_transaction_id` inside `non_subscriptions`, so that field must be there. ## A 4xx finishes a purchase; a 5xx keeps it This is the rule we are strictest about. When `POST /v1/receipts` fails, the SDK looks at the status code. On a 4xx it treats the purchase as permanently bad and finishes the transaction. On a 5xx it keeps the transaction and retries later. You can see the rule in purchases-ios, where a failed post is "finishable" only when it is not a server error ([NetworkError.swift](https://github.com/RevenueCat/purchases-ios/blob/main/Sources/Networking/HTTPClient/NetworkError.swift)). So a server that answers 4xx for its own bug can make a paying customer lose a purchase. RevenueDot never does. Its error handler turns anything unexpected into HTTP 500 with code 7110, and a store that is down or slow becomes 503 with code 7101. Only a receipt that can never be valid gets a 4xx: | HTTP | Code | Meaning | |---|---|---| | 401 | 7225 | Invalid API key | | 400 | 7103 | Invalid receipt: bad signature, wrong bundle ID, malformed token | | 400 | 7102 | Receipt already in use by another user, under the `keep` rule | | 400 | 7662 | Receipts for this store are not supported yet | | 500 | 7234 | App Store in-app purchase key missing or rejected, so the app retries after you fix it | | 503 | 7101 | Apple or Google failed; try again | | 500 | 7110 | Anything unexpected inside RevenueDot | A missing App Store key is a 5xx on purpose. It is your setup problem, not the customer's, so the purchase waits on the device until you add the key. Every code is listed, with where it is thrown, in [4xx or 5xx](https://revenuedot.app/docs/help/receipt-errors-4xx-vs-5xx.md). ## The tests use the SDKs' own samples The RevenueCat SDKs are MIT-licensed, and their test suites contain real request and response samples. We copied 94 of them from purchases-ios and purchases-android into `packages/contract/fixtures`, with the upstream notice. The contract tests do three things with them: 1. Check that the real RevenueCat responses pass our schemas, so the schemas are right. 2. Run the same requests against RevenueDot and check that our responses pass the same schemas, with the same keys. 3. Cover the behaviour around them: 201 versus 200 on `logIn`, 7102 under the `keep` rule, `should_consume` for consumables, the `TRANSFER` event on a restore. For the REST API, the tests validate our `/v2` responses against the response schemas in RevenueCat's published OpenAPI files ([API v2 reference](https://www.revenuecat.com/docs/api-v2)). The spec files are not copied into our repository, so this suite runs where the spec is downloaded. Webhooks get their own test. For every event type we send, it builds the event through the real purchase pipeline and compares it, key by key, with a RevenueCat sample payload. The keys must be the same, `null` where the sample has `null`, and left out where the sample leaves them out. Three keys are on a short, documented list of gaps: `experiments`, `renewal_number` and `metadata`. ## Response signing, byte by byte Recent RevenueCat SDKs can verify that a response came from a server holding the right key. RevenueCat calls this Trusted Entitlements ([RevenueCat docs](https://www.revenuecat.com/docs/customers/trusted-entitlements)). RevenueDot implements the same wire format, read from the MIT source of purchases-ios ([Signing.swift](https://github.com/RevenueCat/purchases-ios/blob/main/Sources/Security/Signing.swift)) and purchases-android. When `REVENUEDOT_SIGNING_KEY` holds an Ed25519 seed, the server adds an `X-Signature` header to every 2xx and 3xx response under `/v1` and `/rcbilling`. It is base64 of 180 bytes: | Bytes | Content | |---|---| | 0 to 31 | An intermediate Ed25519 public key | | 32 to 35 | The intermediate key's expiry, in days since 1970, little-endian | | 36 to 99 | The root key's signature over the expiry and the intermediate key | | 100 to 115 | A random salt | | 116 to 179 | The intermediate key's signature over the message | The message is the salt, then the API key, the request's `X-Nonce`, the raw request path, `X-Post-Params-Hash`, `X-Headers-Hash`, `X-RevenueCat-Request-Time`, `X-RevenueCat-ETag`, and the response body. The server mints an intermediate key in memory, valid for 30 days, and replaces it when 7 days are left. The public root key is at `GET /.well-known/revenuedot-signing-key`. The test for this is simple. An independent verifier first accepts the eight real RevenueCat production signatures published in the purchases-ios test suite, which proves it reads the bytes the way the SDKs do. Then it accepts our signatures, and rejects a changed body, nonce, path, API key or request time, a wrong root key, and an expired intermediate key. ## What proxy mode cannot fix The proxy URL moves the API calls. It cannot change what is compiled into the SDK. - **The signing key.** The stock SDK trusts RevenueCat's key, and we cannot sign with it. With verification on, every RevenueDot response reads as `FAILED`. Apps turn the check off. - **Traffic that ignores the proxy.** The stock Android SDK sends diagnostics, paywall events and ad events to RevenueCat's hosts even behind a proxy. The stock purchases-js sends analytics events to RevenueCat unless you turn them off. - **Platforms where the proxy does not work.** On Flutter web, the stock SDK ignores `setProxyURL`. That is why we maintain MIT forks of all ten SDKs. They carry a different signing key and close these gaps, and they keep every name your code imports. The details are in [Why we forked the RevenueCat SDKs](https://revenuedot.app/blog/why-we-forked-the-revenuecat-sdks.md). **About RevenueDot.** RevenueDot is an open-source (AGPL-3.0), self-hostable backend for in-app purchases and subscriptions that works with the RevenueCat SDK. Point the SDK's proxy URL at your RevenueDot server and keep your app code, your offerings and your customers. Start with the [quickstart](https://revenuedot.app/docs/getting-started/quickstart.md) or read the code on [GitHub](https://github.com/revenuedot/revenuedot). --- # Introducing RevenueDot, an open-source backend for in-app purchases Source: https://revenuedot.app/blog/introducing-revenuedot.md Description: RevenueDot is an open-source, self-hostable backend for in-app purchases that works with the RevenueCat SDK. Here is why we built it, and what it does today. RevenueDot is an open-source (AGPL-3.0), self-hostable backend for in-app purchases and subscriptions. It speaks the same API as RevenueCat's backend, so an app that already uses the RevenueCat SDK can talk to a RevenueDot server by changing one setting: the SDK's proxy URL. Your purchase code, your offerings and your customers stay where they are. This post says what we built, why, and exactly what it does today. ## Why we built it Subscription apps need a backend that checks store receipts, tracks who has access, follows renewals and refunds, and tells the app's own server what happened. RevenueCat made that easy, and its SDKs are some of the best-maintained open-source code in mobile. We wanted three things that a hosted service cannot give. **No share of revenue.** RevenueCat's Pro plan is free up to $2,500 in monthly tracked revenue, then charges 1% of tracked revenue ([RevenueCat pricing](https://www.revenuecat.com/pricing)). At $50,000 a month that is roughly $500 a month. At $500,000 a month it is roughly $5,000 a month, or $60,000 a year. A self-hosted RevenueDot costs what your server and database cost. **Your own data, in your own region.** Purchases, customers, receipts and events live in your Postgres database. You choose where it runs, who can read it and how long it keeps things. Nothing leaves your infrastructure unless you send it. **Code you can read.** The server decides who gets access to your paid features. With RevenueDot you can read that code, test it and change it. ## How it works One server process serves four things on one port: - the **SDK API** under `/v1`, the endpoints the RevenueCat SDKs call; - the **REST API** under `/v2`, for your backend and scripts; - **store notifications** from Apple and Google, under `/v1/notifications/...`; - the **dashboard**, at `/login`. The app changes one line. On iOS: ```swift // Point the SDK at your RevenueDot server; nothing else in the app changes. Purchases.proxyURL = URL(string: "https://revenuedot.example.com")! Purchases.configure(withAPIKey: "appl_...") ``` Every other RevenueCat SDK has the same setting, from React Native's `setProxyURL` to the `httpConfig.proxyURL` option in purchases-js. When the SDK posts a purchase, RevenueDot verifies it with Apple or Google, stores it, answers with the customer's entitlements, and sends a webhook to your backend. The webhook has the same JSON shape and the same kind of signature header as RevenueCat's, so an existing handler keeps working. A real answer from a local server, after a Test Store purchase, starts like this: ```json { "request_date": "2026-09-30T20:41:54Z", "subscriber": { "entitlements": { "pro": { "expires_date": "2026-10-30T20:41:54Z", "product_identifier": "pro_monthly", "purchase_date": "2026-09-30T20:41:54Z", "grace_period_expires_date": null } }, "original_app_user_id": "user_1" }, "purchased_products": { "pro_monthly": { "should_consume": false } } } ``` ## What works today - **The SDK endpoints**: customer info, offerings, receipts, `logIn`, attributes, the product-to-entitlement mapping for offline entitlements, and the configuration and event endpoints the SDKs call on start. - **Four stores for receipts**: App Store, Mac App Store, Google Play and our own Test Store. StoreKit 2 signed transactions are verified against Apple's certificate chain. Google purchases are read from the Play Developer API and acknowledged. - **Store notifications**: App Store Server Notifications v2 and Google Play real-time notifications, with a setup-health check that tells you whether they arrive. - **Identity**: anonymous IDs, `logIn`, aliases, and the four restore rules (`transfer`, `transfer_if_no_active`, `keep`, `share`). - **Webhooks**: fifteen event types, HMAC signatures, an `Authorization` header you choose, and retries after 5, 10, 20, 40 and 80 minutes. - **REST API v1 and the core of v2**: customers, subscriptions, purchases, entitlements, offerings, packages, products, apps and projects. - **A dashboard** with overview metrics, customers, catalog, webhooks, API keys and setup health. - **Response signing** in the same format the SDKs verify. - **An importer** that copies a RevenueCat project's catalog, customers and public app keys. It runs from source today. - **MIT forks of all ten RevenueCat SDKs**, patched and checked, but not yet published to any package registry. ## What does not work yet We would rather you hear this from us than find out in production. - **No real App Store or Google Play sandbox purchase has run end to end.** The store code is tested against mocked Apple and Google APIs only. - **Paywalls, experiments, targeting, Customer Center and virtual currencies** are not built. The SDK hides them instead of crashing. - **Amazon, Stripe, Web Billing, Paddle and Roku** receipts are refused. - **With the stock SDK, response signatures read as FAILED**, because we cannot sign with RevenueCat's key. You turn the check off, or build our forks with your own key. - **The SDK forks are not published yet.** They run from source. The importer CLI (`npx revenuedot`) and the local MCP server (`npx -y @revenuedot/mcp`) are on npm, and the hosted MCP server at `https://mcp.revenuedot.app/mcp` works. The full list, with a workaround for each item, is in [Known issues](https://revenuedot.app/docs/help/known-issues.md). ## How to try it The fastest way is RevenueDot Cloud: sign up at [app.revenuedot.app](https://app.revenuedot.app) (free plan) and point your SDK's proxy URL at `https://api.revenuedot.app`. To run it yourself you need Docker, `curl` and `jq`. The quickstart takes about five minutes, and most of that is the first image build: ```bash git clone https://github.com/revenuedot/examples.git cd examples/selfhost/docker-compose cp .env.example .env # set POSTGRES_PASSWORD docker compose up -d ./seed.sh # prints a Test Store key and a secret key ``` Then point the [purchases-js example](https://github.com/revenuedot/examples/tree/main/web/purchases-js-vite) at `http://localhost:8787` and buy something. The [quickstart](https://revenuedot.app/docs/getting-started/quickstart.md) walks through each step. If you already use RevenueCat, read [Migrate from RevenueCat](https://revenuedot.app/docs/migrate.md) before you touch production. The order of steps matters. ## What comes next Our next milestones, in order: 1. Real App Store and Google Play sandbox purchases, end to end. 2. Published SDK forks, a published importer and a published MCP package. RevenueDot Cloud is already live at [app.revenuedot.app](https://app.revenuedot.app), and the native iOS and Android SDKs load and buy Test Store products. The build plan is public in the repository. RevenueDot is not affiliated with RevenueCat. "RevenueCat" is a trademark of RevenueCat, Inc., and we use it only to describe compatibility. **About RevenueDot.** RevenueDot is an open-source (AGPL-3.0), self-hostable backend for in-app purchases and subscriptions that works with the RevenueCat SDK. Point the SDK's proxy URL at your RevenueDot server and keep your app code, your offerings and your customers. Start with the [quickstart](https://revenuedot.app/docs/getting-started/quickstart.md) or read the code on [GitHub](https://github.com/revenuedot/revenuedot). --- # Migrating from RevenueCat without losing a subscriber Source: https://revenuedot.app/blog/migrating-from-revenuecat-without-data-loss.md Description: The safe order for moving to RevenueDot: import customers and keep your public keys, forward store notifications to run both systems, verify, then switch with an app update. A safe migration never has a moment when a paying customer lacks access. With RevenueDot you get there in five steps: **import** your RevenueCat project, **keep the public keys** your apps already ship, **run both systems side by side** by forwarding store notifications, **verify** that they agree, and then **switch** with an app update that sets the proxy URL. RevenueCat keeps working the whole time, so you can stop at any step. Rehearse everything below on a copy of your project first, then run both systems side by side before you switch live customers. ## Step 1: import the project The importer reads your RevenueCat project through RevenueCat's REST API v2 with a read-only secret key, and writes to your RevenueDot server through its REST API. It runs on your machine, so your keys and data never pass through anyone else. It is on npm as `revenuedot`: ```bash npx revenuedot import --from-revenuecat \ --rc-key sk_... --rc-project proj... \ --to https://revenuedot.example.com --to-key sk_... --dry-run ``` Drop `--dry-run` to write. What comes over: - **The catalog**: apps, products, entitlements and the products attached to them, offerings with their metadata and the current flag, and packages. - **Customers**: IDs, aliases, attributes, subscriptions with their store transactions, and one-time purchases. - **Dates**: first-seen dates and original purchase dates, so your cohorts stay honest. What does not come over: paywalls, targeting rules, experiments and virtual currency balances. Refunded subscriptions import as expired, because RevenueCat's API does not expose the refund. RevenueCat Billing subscriptions keep their access, but their renewals stay with RevenueCat. The import is **quiet**: it sends no webhooks and records no lifecycle events, so your backend does not see thousands of fake `INITIAL_PURCHASE` events. Pass `--emit-events` if you want them. It is also **resumable and idempotent**. A state file tracks progress, so a stopped run carries on where it stopped, and a second run changes nothing that is already right. That makes the same command your incremental sync during the side-by-side run. The importer makes about five RevenueCat API calls per customer, and when RevenueCat answers 429 it waits for the time in `Retry-After`. A large project takes hours, not minutes, so start it early. `--concurrency`, `--limit` for a trial run, and `--json` for a machine-readable report are there when you need them. ## Step 2: keep the public keys you already ship Your app binaries contain RevenueCat public keys such as `appl_...` and `goog_...`. By default the importer sets each RevenueDot app's public key to the same value, through `POST /v2/projects/{project_id}/import/apps/{app_id}/public_key`. Public keys are public by design, so this is safe. The result: the app update that switches to RevenueDot changes only the proxy URL, and not the key. Pass `--no-public-keys` if you would rather issue new keys. ## Step 3: fill in the Google purchase tokens Apple purchases carry an original transaction ID that both systems share, and RevenueDot confirms it with Apple when the app's in-app purchase key is set. Google is harder: RevenueCat's API exposes Google **order IDs**, not the **purchase tokens** that Google's API needs. RevenueDot fills them in from four places: 1. **Google's orders API.** When the Play app's service account is set, the server looks up the token for each imported order ID during the import. 2. **A token file.** If you have the tokens, pass `--google-tokens tokens.csv`, with a `purchase_token` column plus `order_id`, or `app_user_id` and `product_id`. 3. **Renewal notifications.** Each Google notification carries the token and upgrades the imported record. 4. **One `syncPurchases()` in the app.** The SDK posts the device's tokens. Until a token arrives, a subscription waits under the key `needs_token_refresh:`. The customer keeps the access that was imported. Check what is left with `GET /v2/projects/{project_id}/import/status`, which counts `needs_token_refresh` per app. ## Step 4: run both systems side by side Now make both systems hear about every renewal, cancellation and refund. Point the stores at RevenueDot, and let RevenueDot forward each notification to RevenueCat: ```bash curl -s -X POST https://revenuedot.example.com/v2/projects/$PROJECT_ID/apps/$APP_ID \ -H "Authorization: Bearer $SECRET_KEY" -H "Content-Type: application/json" \ -d '{"app_store":{"notification_forward_url":"https://"}}' ``` Use `play_store` for a Google Play app. In the dashboard, the same setting is on the app page, under **Forward notifications to RevenueCat or your own server**. Then set RevenueDot's notification URL in App Store Connect, both production and sandbox, and on the Pub/Sub push subscription. For each notification, RevenueDot stores it, applies it, and copies the exact body to the forward URL. The forward runs in the background with a 10-second timeout, so a slow RevenueCat never delays Apple or Google. The last forward's HTTP status is shown on the app's store settings. For Google you can instead add a second push subscription on the same Pub/Sub topic, so each system gets its own copy. Turn on **Track new purchases from server-to-server notifications** too (the `track_new_purchases` credential). Without it, a notification about a purchase RevenueDot has never seen is stored but not applied. During a dual run, people keep buying through RevenueCat, so you want RevenueDot to pick those purchases up. Run the importer again from time to time. It picks up customers and purchases created in RevenueCat since the last run. ## Step 5: verify, then switch Compare the two systems customer by customer: ```bash pnpm --filter revenuedot cli import verify \ --rc-key sk_... --rc-project proj... --to https://revenuedot.example.com --to-key sk_... ``` It compares each customer's active entitlements, their expiry dates and the number of subscriptions that give access, plus totals. It exits with 0 when everything matches and 1 when it finds differences, so you can run it in CI. Re-run the import, then verify again, until the differences you see are ones you understand. Then ship the app update. The change is the proxy URL, set before `configure`, and turning off the SDK's response-signature check: ```swift // Point the SDK at your RevenueDot server; nothing else in the app changes. Purchases.proxyURL = URL(string: "https://revenuedot.example.com")! Purchases.configure(with: Configuration.Builder(withAPIKey: "appl_...") .with(entitlementVerificationMode: .disabled) .build()) ``` Users on older versions keep talking to RevenueCat, and the forwarded notifications keep RevenueCat correct for them. Point your backend's webhook handler at RevenueDot's webhooks: the payload has the same shape, and RevenueDot adds an HMAC signature header you should verify. When nearly all active users run the new version, stop forwarding and turn RevenueCat off. `revenuedot import plan` prints these cutover steps with your own app IDs and URLs. ## The short version 1. Import with a dry run, then for real. 2. Keep your public keys. 3. Let Google tokens fill in. 4. Forward notifications and track new purchases. 5. Verify, ship the proxy URL, wait for adoption, then turn RevenueCat off. The full guides are in [Migrate from RevenueCat](https://revenuedot.app/docs/migrate.md), with the [importer](https://revenuedot.app/docs/migrate/importer.md), the [dual run](https://revenuedot.app/docs/migrate/dual-run.md) and the [cutover checklist](https://revenuedot.app/docs/migrate/cutover-checklist.md). **About RevenueDot.** RevenueDot is an open-source (AGPL-3.0), self-hostable backend for in-app purchases and subscriptions that works with the RevenueCat SDK. Point the SDK's proxy URL at your RevenueDot server and keep your app code, your offerings and your customers. Start with the [quickstart](https://revenuedot.app/docs/getting-started/quickstart.md) or read the code on [GitHub](https://github.com/revenuedot/revenuedot). --- # Self-host RevenueDot in 5 minutes Source: https://revenuedot.app/blog/self-host-revenuedot-in-5-minutes.md Description: Start RevenueDot with Docker Compose, seed a Test Store project, make a first purchase with curl, receive the webhook on your laptop, and turn on response signing. You can run RevenueDot on your laptop, make a purchase and receive the webhook in about five minutes, most of which is the first image build. You need Docker with Compose v2, `curl`, `jq` and Node.js 18 or newer for the webhook receiver. You do not need an App Store or Google Play account: the built-in Test Store stands in for them. Everything below uses the public [examples repository](https://github.com/revenuedot/examples). For production, follow the [going-to-production checklist](https://revenuedot.app/docs/guides/going-to-production.md) afterwards. ## 1. Start the server and Postgres ```bash git clone https://github.com/revenuedot/examples.git cd examples/selfhost/docker-compose cp .env.example .env # set POSTGRES_PASSWORD before the first start docker compose up -d # builds RevenueDot from source the first time curl http://localhost:8787/v1/health ``` ```json {"status":"ok"} ``` The Compose file runs two containers. `revenuedot` is one Node.js process that serves the SDK API under `/v1`, the REST API under `/v2`, store notifications, and the dashboard at `http://localhost:8787/login`. `db` is Postgres 16 with a named volume, so your data survives restarts. There is no published image yet, so Compose builds one from [the source on GitHub](https://github.com/revenuedot/revenuedot). Two details save time later: - **Migrations run on start.** Every time the server starts, it applies any new database migrations before it listens. An upgrade is a rebuild and a restart. - **Port 8787 taken?** Set `REVENUEDOT_PORT=8797` in `.env`, and use that port below. ## 2. Start a webhook receiver Your backend learns about purchases through webhooks. The examples include small receivers in many languages. Start the Express one in a second terminal: ```bash cd examples/backend/node-express-webhook npm install cp .env.example .env npm start # http://localhost:3000/webhooks/revenuedot ``` ## 3. Seed a project The [seed script](https://github.com/revenuedot/examples/blob/main/selfhost/docker-compose/seed.sh) uses only the public API. It signs up a dashboard account, which creates the first project, then creates a Test Store app, three products, a `pro` entitlement, a `default` offering and a secret key. With `WEBHOOK_URL` set, it also creates a webhook: ```bash cd examples/selfhost/docker-compose WEBHOOK_URL=http://host.docker.internal:3000/webhooks/revenuedot ./seed.sh ``` ```text RevenueDot is seeded. Dashboard http://localhost:8787 (sign in as dev@example.com) Project id proj18pzzkao Test Store key test_... <- the SDK's API key; the SDK's proxy URL is http://localhost:8787 Secret key sk_... <- server-side only (REST API, backend checks) Webhook secret whsec_... <- REVENUEDOT_WEBHOOK_SECRET in your backend ``` `host.docker.internal` is how the container reaches your computer on Mac and Windows. On Linux, add `extra_hosts: ["host.docker.internal:host-gateway"]` to the `revenuedot` service. Put the webhook secret into the receiver's `.env` as `REVENUEDOT_WEBHOOK_SECRET` and restart it with `npm start`. Then save the keys for the next steps: ```bash export TEST_KEY=test_... SECRET_KEY=sk_... PROJECT_ID=proj... ``` The script is safe to run twice. Set `RD_EMAIL` and `RD_PASSWORD` to choose the dashboard account it creates. ## 4. Make a first purchase with curl Ask for offerings the way the SDK does: ```bash curl -s -H "Authorization: Bearer $TEST_KEY" http://localhost:8787/v1/subscribers/user_1/offerings ``` Then post a purchase. In an app, the SDK makes this call after the user confirms the Test Store dialog. The Test Store accepts any token of the form `test__`: ```bash curl -s http://localhost:8787/v1/receipts \ -H "Authorization: Bearer $TEST_KEY" -H "Content-Type: application/json" \ -d "{\"app_user_id\":\"user_1\",\"fetch_token\":\"test_$(date +%s)000_quickstart\",\"product_id\":\"pro_monthly\",\"price\":9.99,\"currency\":\"USD\"}" ``` The answer is the customer info the SDK decodes, with `pro` active for a month: ```json {"request_date":"2026-09-30T20:41:54Z","subscriber":{"entitlements":{"pro":{"expires_date":"2026-10-30T20:41:54Z","grace_period_expires_date":null,"product_identifier":"pro_monthly","purchase_date":"2026-09-30T20:41:54Z"}},"original_app_user_id":"user_1","subscriptions":{"pro_monthly":{"store":"test_store","is_sandbox":true,"period_type":"normal","price":{"amount":9.99,"currency":"USD"},"...":"..."}}},"purchased_products":{"pro_monthly":{"should_consume":false}}} ``` A bad token shows the error format. RevenueDot answers 4xx only when a purchase can never be valid, because a 4xx makes the SDK finish the transaction for good: ```json {"code":7103,"message":"The receipt is not a valid Test Store purchase token."} ``` ## 5. Watch the webhook arrive Within a second, the receiver gets a `POST` like this one, captured from a local run: ```text content-type: application/json user-agent: RevenueDot-Webhooks/1.0 x-revenuecat-webhook-signature: t=1790800914,v1=5e6c0809f9b7b24f7ae36f4744b3b04868222411db4b24c60e6e390c5e9c495f {"event":{"id":"66339910-3BFF-49F4-B873-D1283D673DE2","type":"INITIAL_PURCHASE","store":"TEST_STORE","environment":"SANDBOX","app_user_id":"user_1","product_id":"pro_monthly","price":9.99,"currency":"USD","entitlement_ids":["pro"],"period_type":"NORMAL","expiration_at_ms":1793392914000,"...":"..."},"api_version":"1.0"} ``` The receiver checks `v1` against an HMAC-SHA256 of `"."` with the webhook secret, ignores repeats of the same `event.id`, and answers 200. Only 200 counts as delivered. Anything else is retried after 5, 10, 20, 40 and 80 minutes. To see the rest of a subscription's life without waiting a month, simulate it: ```bash curl -s -X POST http://localhost:8787/v2/projects/$PROJECT_ID/test_purchases \ -H "Authorization: Bearer $SECRET_KEY" -H "Content-Type: application/json" \ -d '{"app_user_id":"user_2","product_id":"pro_monthly","scenario":"renewal"}' ``` The scenarios are `purchase`, `trial`, `trial_conversion`, `renewal`, `cancel`, `billing_issue`, `refund` and `expire`. Each one sends the same webhooks a real store would cause. A `refund`, for example, sends `INITIAL_PURCHASE` and then `CANCELLATION` with `cancel_reason: CUSTOMER_SUPPORT` and a negative price. ## 6. Turn on response signing Recent RevenueCat SDKs can check that responses are signed. RevenueDot signs every successful SDK response when `REVENUEDOT_SIGNING_KEY` holds a base64 Ed25519 seed of 32 random bytes. You can make one with `openssl`, or with `pnpm tsx scripts/signing-keygen.ts` in a checkout of the server repository, which also prints the public key: ```bash echo "REVENUEDOT_SIGNING_KEY=$(openssl rand -base64 32)" >> .env ``` The example's `docker-compose.yml` passes only `DATABASE_URL` and `PORT` to the container, so add the key under the `revenuedot` service's `environment`: ```yaml REVENUEDOT_SIGNING_KEY: ${REVENUEDOT_SIGNING_KEY} ``` Then restart and fetch the public key: ```bash docker compose up -d curl -s http://localhost:8787/.well-known/revenuedot-signing-key ``` ```json {"algorithm":"Ed25519","public_key":"ZzwPxGlon0E8ErpDh9QAH0Jh6+E6D6qufvTSetXZY9Y=","encoding":"base64","header":"X-Signature","docs":"https://revenuedot.app/docs"} ``` Responses now carry an `X-Signature` header. The stock RevenueCat SDK still cannot use it, because it trusts only RevenueCat's key, so keep its verification turned off. To get `VERIFIED` in your app, build the RevenueDot SDK forks with your public key. See [Trusted Entitlements](https://revenuedot.app/docs/guides/trusted-entitlements.md). ## 7. Point an app at it Use the Test Store key as the SDK's API key and your server as the proxy URL: ```ts // Point the SDK at your RevenueDot server; nothing else in the app changes. await Purchases.setProxyURL("http://localhost:8787"); Purchases.configure({ apiKey: "test_..." }); ``` The quickest end-to-end check is the [purchases-js example](https://github.com/revenuedot/examples/tree/main/web/purchases-js-vite), which buys through the Test Store in a browser. The unmodified iOS and Android SDKs also buy Test Store products on a simulator or emulator. ## Before you go to production Your laptop setup is not a production setup. Before real customers: 1. **Serve HTTPS** behind a reverse proxy. Apple and Google send notifications only to public HTTPS URLs. 2. **Set a strong `POSTGRES_PASSWORD`** before the first start. It is stored in the volume, and changing it later needs `ALTER USER` too. 3. **Back up Postgres** on a schedule, and test a restore: `docker compose exec -T db pg_dump -U revenuedot -Fc revenuedot > backup.dump`. 4. **Add store credentials per app**: the App Store in-app purchase key and the Google service account. They live on each app, not in environment variables. 5. **Set the notification URLs** in App Store Connect and on the Pub/Sub push subscription, then check `notification_status` in setup health. 6. **Keep the signing seed secret.** Keep it out of every repository and every image. 7. **Never set `allow_unsigned_receipts`** outside development. 8. **Run one server container per database.** Expirations and webhooks run in a background job inside the container. 9. **Remember the status.** Store purchases are tested against mocked Apple and Google APIs only, and no real sandbox purchase has run end to end yet. The full list is in [Going to production](https://revenuedot.app/docs/guides/going-to-production.md), with [Backups](https://revenuedot.app/docs/guides/backups.md) and [Upgrades](https://revenuedot.app/docs/guides/upgrades.md). **About RevenueDot.** RevenueDot is an open-source (AGPL-3.0), self-hostable backend for in-app purchases and subscriptions that works with the RevenueCat SDK. Point the SDK's proxy URL at your RevenueDot server and keep your app code, your offerings and your customers. Start with the [quickstart](https://revenuedot.app/docs/getting-started/quickstart.md) or read the code on [GitHub](https://github.com/revenuedot/revenuedot). --- # Why we forked the RevenueCat SDKs Source: https://revenuedot.app/blog/why-we-forked-the-revenuecat-sdks.md Description: RevenueDot maintains MIT forks of all ten RevenueCat SDKs. What the patches change, why every import name stays the same, and how one script keeps the forks in sync with upstream. RevenueDot keeps MIT-licensed forks of all ten RevenueCat SDKs because a proxy URL can move an SDK's API calls but cannot change what is compiled into it. The forks change four things: the default API host, the response-signing key the SDK trusts, the registry names we publish under, and a few leaks where the stock SDK talks to RevenueCat even behind a proxy. They keep every name your code imports, so switching is a package change, not a code change. One script re-applies the patches after every upstream release. You do not need the forks to use RevenueDot. The stock SDKs work in proxy mode today. The forks are for the gaps below, and they are not published to any package registry yet. ## What proxy mode cannot fix Every RevenueCat SDK lets you set a proxy URL, and RevenueDot answers the calls that arrive there. That covers purchases, customer info, offerings and identity. Three things stay out of reach: 1. **The signing key.** Recent SDKs can verify that responses come from a server holding a known Ed25519 key. RevenueCat calls this Trusted Entitlements ([RevenueCat docs](https://www.revenuecat.com/docs/customers/trusted-entitlements)). The key is compiled into the SDK, and it is RevenueCat's. Against any other server, verification reads `FAILED`. 2. **Traffic that ignores the proxy.** The stock Android SDK sends diagnostics, paywall events and ad events to RevenueCat's hosts even when a proxy URL is set. The stock purchases-js sends analytics events to RevenueCat's events host. 3. **Platforms where the proxy does not work.** On Flutter web, the stock SDK ignores `setProxyURL`: the Dart API sends a message the web plugin does not handle. We could not fix any of these from the server. So we forked. ## Ten repositories, full history The ten forks are [purchases-ios](https://github.com/revenuedot/purchases-ios), [purchases-android](https://github.com/revenuedot/purchases-android), [purchases-hybrid-common](https://github.com/revenuedot/purchases-hybrid-common), [react-native-purchases](https://github.com/revenuedot/react-native-purchases), [purchases-flutter](https://github.com/revenuedot/purchases-flutter), [purchases-js](https://github.com/revenuedot/purchases-js), [purchases-capacitor](https://github.com/revenuedot/purchases-capacitor), [purchases-kmp](https://github.com/revenuedot/purchases-kmp), [purchases-unity](https://github.com/revenuedot/purchases-unity) and [cordova-plugin-purchases](https://github.com/revenuedot/cordova-plugin-purchases). Each is a hard fork with the full history and tags, created on 2026-09-30 from upstream `main`. Every upstream `LICENSE` keeps RevenueCat's MIT notice unchanged, and we add one line after it for our changes. Each README opens with a banner that says it is a fork maintained by RevenueDot and not affiliated with RevenueCat. ## Keep what code imports, rename what a registry owns The rule for names is short: **a name stays when changing it would break app code, and a name changes when it is a registry entry RevenueCat owns.** We cannot publish into RevenueCat's CocoaPods pods, Maven group or npm scope, and we do not want to. Our builds must be easy to tell apart from theirs. | Platform | Published as (planned) | Stays the same in your code | |---|---|---| | iOS | CocoaPods `RevenueDotPurchases`; SPM from our repo, tags `-revenuedot` | `import RevenueCat` | | Android | Maven `app.revenuedot.purchases:purchases` | `com.revenuecat.purchases.*` | | React Native | npm `@revenuedot/react-native-purchases` | `import Purchases from "react-native-purchases"`, through an npm alias | | Flutter | git dependency, tags `-revenuedot` | `package:purchases_flutter` | | Web | npm `@revenuedot/purchases-js` | `@revenuecat/purchases-js`, through an npm alias | | Capacitor | npm `@revenuedot/purchases-capacitor` | installed under the alias `@revenuecat/purchases-capacitor`, so Capacitor's generated native names stay | | Kotlin Multiplatform | Maven `app.revenuedot.purchases:purchases-kmp-core` | `com.revenuecat.purchases.kmp.*` | | Unity | OpenUPM `com.revenuedot.purchases-unity` | `using RevenueCat;` | | Cordova | npm `@revenuedot/cordova-plugin-purchases` | plugin id `cordova-plugin-purchases` and the global `Purchases` | The npm alias is what makes the swap free on JavaScript platforms. For React Native it is one line in `package.json`: ```json { "dependencies": { "react-native-purchases": "npm:@revenuedot/react-native-purchases@10.10.2" } } ``` Every file that says `import Purchases from "react-native-purchases"` keeps working. The fork's version number is the upstream version it is built on, so `10.10.2` is RevenueCat's 10.10.2 plus our patches. We thought hard about renaming the Swift module and the Kotlin packages too. It would have broken every app file and every guide written for the RevenueCat SDK, so we kept them as code-compatibility identifiers. Our products are named RevenueDot, and we never use RevenueCat's logo. ## What the patches change **Hosts.** Every RevenueCat host in shipped code points at `https://api.revenuedot.app`. That includes the main API, the fallback hosts, diagnostics, paywall and ad events, and purchases-js's API and events hosts. `setProxyURL` still overrides all of them, so self-hosters keep setting their own URL. The hosted API at that address, RevenueDot Cloud, is live. **The signing key.** The iOS and Android forks trust RevenueDot's Ed25519 public key instead of RevenueCat's, so Trusted Entitlements verify against RevenueDot. A self-hosted server cannot sign with our key, so self-hosters either keep verification off or build the forks with their own key, one command in the pipeline: ```bash pnpm tsx scripts/forks/apply.ts --var apiHost=https://iap.example.com --var signingPublicKey= ``` **Four small behaviour patches**, each closing a proxy-mode leak: 1. Android: diagnostics, paywall events and ad events honour the proxy URL. 2. purchases-js: analytics events honour `httpConfig.proxyURL`. 3. Flutter web: `setProxyURL` works. 4. purchases-js checkout: "Secure checkout by RevenueCat" reads "Secure checkout by RevenueDot", in all 34 locales. **Packaging.** Registry names, the dependency pins between forks, so our React Native pulls our hybrid-common, which pulls our iOS and Android, and the package metadata with the fork notice. **What we do not change:** class names, method names, API key prefixes such as `appl_` and `goog_`, and log strings. Keeping the prefixes means the web SDK's key check passes against our server unchanged. ## How the forks keep up with upstream RevenueCat ships SDK releases often, and a fork that falls behind is worse than no fork. So the patches are not hand-made commits. They are rules, and one script applies them. Each fork has three branches: - `main` is upstream `main` at fork time plus a one-line notice. The pipeline never pushes to it. - `revenuedot/main-patches` is `main` plus exactly one pipeline commit. We build and publish from it. - `upstream-sync` is where new upstream releases get merged. The rules live in `scripts/forks/rules/.json` in the [server repository](https://github.com/revenuedot/revenuedot). They are string and regex replacements, JSON edits, renames, license lines and banners. The sync script fetches upstream and its tags, merges them into `upstream-sync` with conflicts resolved to upstream, re-applies the rules, runs each fork's checks, and opens a pull request into `revenuedot/main-patches`. Two properties keep this safe: - **Every rule is idempotent and loud.** Running the pipeline twice changes nothing the second time. A rule whose target moved upstream fails with "Rule did not match … update the rule" instead of skipping quietly. - **A leak scan runs last.** It fails the repository if any RevenueCat API host, events host, fallback host, asset CDN or RevenueCat's signing key is left in shipped code. It is clean on all ten forks today. We also run the forked web SDK end to end against a real RevenueDot server. It configures with a proxy URL, reads customer info and offerings, buys through the Test Store, and sees the `pro` entitlement active. Every call, analytics included, went to our server, and a signed response verified with the key baked into the fork. ## What is not done yet - **Publishing.** None of the forks is on npm, CocoaPods, Maven Central or OpenUPM yet. That needs registry accounts and signing keys. - **CI.** The sync runs by hand today. A daily job is next. - **Legal review.** We want counsel to confirm the naming approach before the first public release. - **The web paywall renderer.** purchases-js still pulls RevenueCat's paywall UI package at build time. Until the forks are published, use the stock RevenueCat SDK with a proxy URL, and turn its signature check off. [Connect your app](https://revenuedot.app/docs/getting-started/connect-your-app.md) shows both paths for every platform. **About RevenueDot.** RevenueDot is an open-source (AGPL-3.0), self-hostable backend for in-app purchases and subscriptions that works with the RevenueCat SDK. Point the SDK's proxy URL at your RevenueDot server and keep your app code, your offerings and your customers. Start with the [quickstart](https://revenuedot.app/docs/getting-started/quickstart.md) or read the code on [GitHub](https://github.com/revenuedot/revenuedot).