# CherrySub: the complete A–Z handbook

Reviewed September 9, 2026. This is the complete product documentation, generated from the same chapter sources as the website. The current hosted release is a staging preview. Code examples use placeholders and application-owned authentication where stated.

## Contents

1. [CherrySub from A to Z](#overview)
2. [Your first verified purchase](#quickstart)
3. [Accounts, projects, and your team](#accounts-and-projects)
4. [Apps and environments](#apps-and-environments)
5. [Connect the App Store](#app-store)
6. [Connect Google Play](#google-play)
7. [CherrySub CLI](#cli)
8. [Products](#products)
9. [Access levels and mappings](#access-levels)
10. [Product sets and packages](#product-sets)
11. [API keys and customer tokens](#authentication)
12. [Customer identity and ownership](#customer-identity)
13. [Swift and StoreKit 2](#swift)
14. [Kotlin and Google Play Billing](#android)
15. [Flutter storefront adapter](#flutter)
16. [React Native and TypeScript](#react-native)
17. [Dart core SDK](#dart)
18. [Go token broker](#go)
19. [Purchases, renewals, and restores](#receipts-and-restores)
20. [Dashboard navigation](#dashboard)
21. [Customers and support notes](#customers)
22. [Transactions and the event stream](#transactions-and-events)
23. [Charts and reporting](#analytics)
24. [Metric definitions](#metrics)
25. [Retention, conversion, and cohorts](#cohorts)
26. [Saved views, saved charts, and exports](#exports-and-saved-views)
27. [Outbound webhooks](#webhooks)
28. [Runtime and admin API reference](#api-reference)
29. [Hosted account and dashboard API](#hosted-api)
30. [Sandbox testing and release readiness](#testing)
31. [Troubleshooting](#troubleshooting)
32. [Migrate an existing app](#migration)
33. [Self-hosting and operations](#self-hosting)
34. [Security and data handling](#security)
35. [Limits and release status](#limits-and-release-status)


---

<a id="overview"></a>

# CherrySub from A to Z

CherrySub connects App Store and Google Play purchases to customer access and subscription reporting. A project contains your team and apps. Each app connects store identities, credentials, products, customers, and delivery settings.

This handbook covers the hosted dashboard, the public-client SDKs, the trusted server integration, the CLI, the HTTP APIs, and self-hosted operation. It documents the current staging preview, reviewed against the implementation on September 9, 2026. Read [release status](#limits-and-release-status) before planning a production rollout.

<a id="overview--choose-your-starting-point"></a>

## Choose your starting point

| Your task | Start here | You will finish with |
| --- | --- | --- |
| Integrate a new app | [First verified purchase](#quickstart) | A sandbox transaction and an active access level |
| Connect an existing store | [App Store](#app-store), [Google Play](#google-play), or [CLI](#cli) | An app credential and a notification plan |
| Add purchase handling | [Authentication](#authentication), then your SDK guide | A customer token, safe purchase submission, and completion |
| Investigate a customer | [Customers](#customers) | The relevant identity, transaction, access state, and evidence |
| Understand a chart | [Charts](#analytics), [metric definitions](#metrics), and [cohorts](#cohorts) | The correct filters, denominator, currency, and time window |
| Receive backend updates | [Webhooks](#webhooks) | A signature-verified, idempotent receiver |
| Operate the service | [Self-hosting](#self-hosting) and [security](#security) | A configured origin, recovery procedure, and monitoring plan |

<a id="overview--the-product-model"></a>

## The product model

| Concept | Meaning | Example |
| --- | --- | --- |
| Project | Membership and app ownership boundary | A company or product team |
| App | One CherrySub app, with an Apple identity, Google identity, or both | Your iOS and Android product |
| Product | A store SKU | `pro.monthly` |
| Access level | A stable capability your application checks | `pro` |
| Product access mapping | A versioned rule connecting a SKU to access | Apple `pro.monthly` grants `pro` |
| Product set | An ordered presentation of packages | `default` |
| Package | A named slot in a product set | `monthly_ios` |
| App User ID | An identity chosen by your authenticated application | An opaque user UUID |
| Customer | CherrySub's app-scoped identity record | The aliases, purchases, and access for that user |
| Event | Immutable evidence of a verified observation or derived change | A receipt observation or `access.changed` |
| Transaction | The current projection of a store purchase | A particular purchase or renewal |

Use these names consistently. A product set controls presentation; an access level controls access. Creating one does not create the other. CherrySub does not create store SKUs when you register products in its dashboard.

<a id="overview--how-a-purchase-becomes-access"></a>

## How a purchase becomes access

1. Your app authenticates the user with your backend.
2. Your backend mints a customer token using an app secret key.
3. Your app purchases through StoreKit or Google Play Billing.
4. The SDK submits the signed transaction or purchase token to CherrySub.
5. CherrySub verifies the store evidence, app identity, and customer-token subject before writing an event.
6. In the same transaction, it updates purchase ownership, store projections, mapped access, and any matching outbound delivery work.
7. The SDK completes the storefront purchase after backend acceptance. Your app reads the subscriber's access levels.
8. Store notifications and reconciliation provide later authoritative observations for renewals, refunds, and missed changes.

A cancelled subscription can remain active until its verified expiry. An inactive grant from one product does not remove access supplied by another active grant. Use the subscriber response for the current access decision.

<a id="overview--what-the-current-preview-includes"></a>

## What the current preview includes

The dashboard provides projects, apps, account and team management, store credentials, API keys, products, access mappings, product sets, customers and notes, transactions, events, charts, saved analyses, exports, and webhook delivery inspection. The CLI automates credential setup and basic project/app administration.

Swift, Kotlin, Dart, TypeScript, Flutter storefront adapters, and a trusted Go package are implemented in the source repository. Their existing AppVisionOS package names are intentional. Public package registry publication is not available in this preview; SDK guides describe source-based integration.

This release is not a hosted paywall editor, experimentation service, tax/proceeds calculator, or store refund-issuance console. Those capabilities must not be inferred from a similarly named RevenueCat feature. See [limits and release status](#limits-and-release-status) for the precise boundaries.

<a id="overview--read-the-whole-handbook"></a>

## Read the whole handbook

Use the chapter navigation to read in order, or search by a screen, metric, API field, or error. [Download the complete handbook](https://app-staging.cherrysub.com/docs/cherrysub-handbook.md) to keep every chapter together. The [OpenAPI contract](https://app-staging.cherrysub.com/docs/openapi.yaml) is available separately for tooling.


---

<a id="quickstart"></a>

# Your first verified purchase

Follow this path for one store first, then repeat the store-specific steps for the other platform. You need a verified CherrySub account, access to your store app, a real sandbox/test purchase path, and a backend that authenticates your app's users.

The dashboard preview is at [app-staging.cherrysub.com](https://app-staging.cherrysub.com). Its runtime API is `https://api-staging.cherrysub.com`. Keep this deployment choice consistent in your backend, SDK, CLI, and notification configuration.

<a id="quickstart--1-create-an-account-and-project"></a>

## 1. Create an account and project

Sign up, open the verification email, and finish verification. Use the initial project associated with your account, create an additional project if needed, or accept your team's invitation. The project switcher controls which apps and settings you see. An owner or admin performs configuration; a viewer can inspect the project.

If verification opens in a different browser, sign in there before continuing. Invitation and verification links preserve their local continuation. See [accounts and projects](#accounts-and-projects).

<a id="quickstart--2-add-your-app"></a>

## 2. Add your app

Open **Apps → Add app**. Choose App Store, Google Play, or both. For Apple, search for the public listing or enter its identifiers manually. For Google, enter the exact package name.

An Apple production app needs both its bundle ID and numeric App Store app ID. Save the newly displayed secret and publishable keys: plaintext keys are shown only once. Keep the secret on your backend and the publishable key in your client configuration.

Expected result: the app appears in Apps and can be selected in the project/app switcher. [App configuration details](#apps-and-environments).

<a id="quickstart--3-connect-the-store"></a>

## 3. Connect the store

Open **Integrations → App Store** or **Google Play**. Use **Set up with CLI** for guided provisioning, or **Upload credential** for an existing key.

```sh
cherrysub auth login
cherrysub setup apple APP_UUID --project PROJECT_UUID
```

Apple setup creates an In-App Purchase key after local sign-in, 2FA, team selection, and confirmation. Google automation currently requires your Desktop OAuth client; its command then provisions an app-specific service account and Play permissions.

Expected result: the page shows an app credential and masked account hint. This confirms credential storage, not a successful live purchase. Finish the store's notification setup using the [Apple](#app-store) or [Google](#google-play) guide.

<a id="quickstart--4-define-products-and-access"></a>

## 4. Define products and access

Ensure the actual SKU exists in the store. In CherrySub, create an access level such as `pro` first. Then open **Products → Register product**, select the exact store/product ID, and choose `pro` plus the grant mode. Registration creates that explicit access mapping; you do not need to add the same mapping again.

For a normal subscription, use `store_managed`. Use `lifetime` only when access should have no scheduled expiry while still responding to verified revocation. Mapping changes apply from their event boundary; they do not replay old history with today's catalog.

Expected result: the product detail shows the access mapping. [Products](#products) · [Access levels](#access-levels).

<a id="quickstart--5-select-a-product-set"></a>

## 5. Select a product set

Create a product set such as `default`, add packages for the products your purchase screen presents, and mark it as the app's current set. Give each package a unique identifier and position. The SDK reads that order and then asks the storefront for localized prices and purchase options.

Expected result: `GET /v1/product-sets/current` returns a non-null set. An empty current set or a missing platform package must produce an honest empty purchase screen, not a hard-coded fallback. [Product sets](#product-sets).

<a id="quickstart--6-implement-the-token-broker-and-sdk"></a>

## 6. Implement the token broker and SDK

Your authenticated backend calls `POST /v1/client-tokens` with `iap_sk_`. It chooses `app_user_id` from the verified application session, then returns only `token` and `expires_at` to the client.

Configure the SDK with `iap_pk_`, the runtime API URL, and a token provider. Follow [authentication](#authentication), then [Swift](#swift), [Android](#android), [Flutter](#flutter), [React Native](#react-native), or [Dart](#dart). A [Go broker example](#go) is available.

Expected result: the client can obtain a token and read its current product set. A subscriber read may return 404 before the customer's first qualifying receipt; that is not permission to invent active access.

<a id="quickstart--7-make-and-verify-a-sandbox-purchase"></a>

## 7. Make and verify a sandbox purchase

Purchase through a store-backed sandbox/test path. Submit StoreKit's transaction JWS or the Google purchase token. Google requests also name `subscription` or `one_time_product`.

Complete the store transaction only after CherrySub accepts the receipt. Read the subscriber again and check `access_levels.pro.active`.

In the dashboard, use the same app and the sandbox environment. Open Transactions, locate the purchase, inspect its customer, and verify the product mapping and active access. Then inspect the corresponding event evidence.

<a id="quickstart--8-exercise-the-rest-of-the-lifecycle"></a>

## 8. Exercise the rest of the lifecycle

Test a renewal, cancellation that remains active until expiry, refund/revocation, duplicate submission, restore under your intended account policy, logout/token invalidation, and a failed webhook receiver. Follow the [test matrix](#testing).

You are ready for the next rollout stage when the storefront result, CherrySub transaction, customer access, and your application's protected feature agree. The dashboard setup checklist is a navigation aid; it is not a substitute for this end-to-end proof.


---

<a id="accounts-and-projects"></a>

# Accounts, projects, and your team

A CherrySub account identifies a person using the dashboard or CLI. A project owns team memberships and apps. Neither is the same identity as an end customer purchasing inside your app.

<a id="accounts-and-projects--sign-up-and-verify-email"></a>

## Sign up and verify email

Enter your first name, surname, email, and password. Passwords require uppercase and lowercase letters plus a special character. The form accepts 10–72 characters; the backend enforces a 72-byte upper bound, which matters for non-ASCII passwords.

Registration creates the account, its initial project, and a session, then requests a verification email. Dashboard operations require a verified email. Verification links expire after 24 hours by default; request a new link from the verification screen when needed. If delivery is unavailable, the screen reports that state rather than confirming an email was sent.

A verification link can be opened in a fresh browser. Sign in if necessary and continue to the original local page. The application does not accept an arbitrary external redirect as the continuation.

<a id="accounts-and-projects--create-and-select-projects"></a>

## Create and select projects

Use **Create project**, enter its name, and continue into the new project. Its creator becomes the owner. Use the top project/app switcher to move between projects and apps. A URL from another project does not grant membership or silently move its data into your current project.

Changing projects replaces the active context. Finish or save form work before switching. Project settings display identity, team membership, invitations, and activity. Rename a project there when your role permits it.

<a id="accounts-and-projects--roles-and-permissions"></a>

## Roles and permissions

| Capability | Owner | Admin | Viewer |
| --- | --- | --- | --- |
| Read the project's dashboard and data | Yes | Yes | Yes |
| Configure apps, catalog, keys, stores, and webhooks | Yes | Yes | No |
| Maintain customer support notes | Yes | Yes | No |
| Invite admins or viewers | Yes | Yes | No |
| Change/remove a viewer | Yes | Yes | No |
| Change/remove another admin | Yes | No | No |
| Change/remove the owner | No | No | No |
| Change/remove your own membership through these controls | No | No | No |

Viewer settings show **View access** and disable configuration controls. Personal saved analyses and requester-private exports use their own account/project authorization; a disabled management button does not imply you cannot read or analyze data.

<a id="accounts-and-projects--invite-a-teammate"></a>

## Invite a teammate

1. Open **Project settings** in the intended project.
2. Enter the teammate's email and select Admin or Viewer.
3. Send the invitation. The invited person opens the link and signs in or registers with the matching email.
4. After email verification, accept the invitation and enter the project.

Invitations expire after seven days. Pending invitations can be revoked, and an expired/revoked link requires a new invitation. A forwarded link does not let a different email accept membership. The API rejects direct member creation: membership must pass through invitation acceptance.

The current controls do not implement ownership transfer or self-service project deletion. Do not remove the owner by editing data directly.

<a id="accounts-and-projects--passwords-and-sessions"></a>

## Passwords and sessions

**Account** provides change-password and signed-in-device controls. Changing a password requires the current password and a different valid new password. The change flow retains the current session while revoking other sessions. Password reset is a separate email-token flow; reset links expire after 30 minutes and successful reset revokes existing sessions.

Use **Forgot password** when you cannot sign in. Its response avoids revealing whether another person's email has an account. A missing email is therefore not proof the account does not exist; check spelling, delivery, spam, and the latest reset link.

Sign out of this session, revoke a named session, or sign out everywhere as appropriate. Revoking the current session returns you to sign-in. Hosted sessions default to seven days; self-hosted operators can configure their lifetime. [Session API](#hosted-api).

<a id="accounts-and-projects--common-recovery-paths"></a>

## Common recovery paths

- **403 / View access:** verify the selected project and ask an owner/admin for the required role.
- **Invitation belongs to another email:** sign out and use the invited address.
- **Expired session:** sign in again; protected-page navigation preserves the local destination.
- **Missing email:** check the account address and email gateway availability before repeatedly resending.
- **A shared chart will not open:** the recipient still needs access to its project and app.


---

<a id="apps-and-environments"></a>

# Apps and environments

A CherrySub app owns its store identities, credentials, API keys, catalog, customers, and purchase data. It can contain an Apple identity, a Google identity, or both. The selected app is visible in the dashboard switcher; confirm it before editing configuration or investigating a purchase.

<a id="apps-and-environments--add-an-app"></a>

## Add an app

Open **Apps → Add app** and choose the platforms you support. For Apple, search the public App Store listing by name or supported store identifier, select the correct match, and review the populated details. If the app is not public yet, enter its identifiers manually. Changing the search removes stale match selection; review the final bundle and app ID before saving.

For Google, enter the exact package name. Choosing both platforms requires both store identities. Your display name can differ from the public store name.

| Field | Purpose |
| --- | --- |
| App name | A label in CherrySub; it does not rename the store listing |
| Apple bundle ID | Matches the signed bundle in Apple purchases and notifications |
| Apple App ID | The numeric App Store identifier; production verification also checks this signed value |
| Google package name | Identifies the application used for Android Publisher reads |

Creating an app in CherrySub does not create its App Store Connect record, Play Console app, store products, tax agreement, or storefront purchase UI.

<a id="apps-and-environments--save-the-first-keys"></a>

## Save the first keys

The hosted app-creation flow displays one secret key and one publishable key. Copy them before leaving the result screen. Plaintext is not available from the later list.

Use `iap_sk_` only on your server. Use `iap_pk_` in the client SDK with a short-lived customer token. If you lose a key, create another and revoke the lost one as needed. [Key lifecycle](#authentication).

<a id="apps-and-environments--inspect-and-edit-app-details"></a>

## Inspect and edit app details

Open an app from the Apps list to review store identities, safe key hints, integration status, and the catalog. Use **Edit app** to update supported identity/name fields. An app-specific detail can be opened without making it the selected reporting app; use its selection action when you want the rest of the dashboard to follow it.

Changing a store identifier does not rewrite old signed purchases. Treat it as a configuration change that must match the actual store app. For a different product or deployment boundary, creating a separate app is usually the clearer integration path.

The dashboard shows whether an app is enabled. Root administration can soft-disable an app: attribution and key authentication stop, keys are revoked, queued webhook work is cancelled, and history remains stored. That is not deletion of its ledger. [Admin API](#api-reference).

<a id="apps-and-environments--two-different-environment-choices"></a>

## Two different environment choices

**Deployment environment** chooses the CherrySub installation: for example, staging API and staging dashboard versus a separately configured production installation. Keys and database state belong to that installation.

**Store environment** is verified purchase attribution inside an installation: `sandbox`, `production`, or, where the evidence does not yet establish it, `unknown`.

Changing a dashboard filter changes what you inspect. It does not turn a purchase into production or move credentials to another deployment. Apple signs the environment. Google RTDN arrives without a purchase environment, so the notification can be `unknown` until the authoritative purchase read establishes it. Unknown is never silently counted as production.

<a id="apps-and-environments--app-identity-is-not-a-store-connection"></a>

## App identity is not a store connection

A bundle ID or package name alone does not configure the server credential. Complete the corresponding [App Store](#app-store) or [Google Play](#google-play) setup. A saved credential also does not prove notification wiring or a successful purchase; the [test matrix](#testing) supplies that proof.

If a connection page says **Add your store identifier**, follow **Open app settings**, add the missing identity, and return to that store. If there is no selected app, add or select one first.


---

<a id="app-store"></a>

# Connect the App Store

Apple connection has three parts: matching your app identity, storing an In-App Purchase credential for server API work, and receiving App Store Server Notifications V2. A successful credential save does not complete the other two parts.

<a id="app-store--before-you-start"></a>

## Before you start

Have an App Store Connect app record, its bundle ID, its numeric App Store app ID for production, and an Apple account permitted to manage In-App Purchase keys. Create the store products and complete the relevant store agreements and availability settings before testing purchases.

In CherrySub, select the correct project and app. If the bundle ID is missing, use the connection page's **Open app settings** recovery action.

<a id="app-store--guided-cli-setup"></a>

## Guided CLI setup

```sh
cherrysub auth login
cherrysub setup apple APP_UUID --project PROJECT_UUID
```

The command signs in directly to Apple, handles 2FA, lets you select a team, checks that the bundle exists in that team, checks key access, and asks before creating the In-App Purchase key. The key is uploaded directly to CherrySub. Your Apple password and Apple session cookies are not saved to a CLI profile.

Use `--sms` for SMS verification or `--team-id` to choose an explicit App Store Connect provider. `--dry-run` prints the CherrySub-side plan without signing in to Apple. Existing app credentials are kept unless `--rotate` is provided. The CLI never automatically revokes old Apple keys.

See the [complete CLI reference](#cli) for installation, scripting, and recovery from a partially completed setup.

<a id="app-store--upload-an-existing-key"></a>

## Upload an existing key

1. Open **Users and Access → Integrations → In-App Purchase** in App Store Connect.
2. Generate/download the In-App Purchase `.p8` key and copy its Key ID and Issuer ID.
3. In CherrySub, open **Integrations → App Store → Upload credential**.
4. Choose the `.p8` file. Recognized `SubscriptionKey_ID.p8` or `AuthKey_ID.p8` filenames populate Key ID.
5. Review or enter the Key ID, paste the Issuer ID, and save.

The file limit is 16 KiB. Pasting the complete PEM is available under **Paste key instead**. Use an In-App Purchase key: a general App Store Connect API key can have a similar PEM format but serves a different purpose. Apple permits a one-time private-key download. [Apple's key instructions](https://developer.apple.com/help/app-store-connect/configure-in-app-purchase-settings/generate-keys-for-in-app-purchases).

<a id="app-store--configure-server-notifications"></a>

## Configure server notifications

In the app's App Store Connect settings, configure App Store Server Notifications **Version 2**. For this staging installation, the sandbox notification URL is:

```text
https://api-staging.cherrysub.com/ingest/apple/assn
```

For another deployment, use its own HTTPS API origin with `/ingest/apple/assn`. Configure sandbox and production destinations deliberately. Apple can send sandbox notifications to the production URL when no sandbox URL is configured, so do not rely on an empty sandbox field as isolation. [Apple's notification URL instructions](https://developer.apple.com/help/app-store-connect/configure-in-app-purchase-settings/enter-server-urls-for-app-store-server-notifications/).

Do not add a CherrySub app API key to this store notification URL. Apple authenticates the payload through its signed evidence. CherrySub verifies the chain, certificate purpose, ES256 signatures, and OCSP status, independently verifies nested signed data, and checks the signed app identity and environment.

<a id="app-store--verify-the-complete-connection"></a>

## Verify the complete connection

Refresh the connection page after CLI setup. Confirm the app credential's safe hint, then send a store-backed sandbox purchase through your SDK. Inspect its transaction and customer access in the same app/environment.

A notification test proves notification transport, not a paid purchase or working access mapping. Receipt validation can be available independently of Apple server API history credentials; avoid treating every capability as one universal green connection flag.

Xcode-local StoreKit testing is not proof that server-side Apple-chain verification works. Use the store-backed sandbox path for the end-to-end check. Missing/invalid signatures are rejected, and an unavailable certificate-status responder is a transient failure for retry, not a verification bypass.

<a id="app-store--rotate-or-disconnect"></a>

## Rotate or disconnect

Upload a replacement or explicitly run CLI setup with `--rotate`. Check a purchase/server read before revoking the old key in Apple. If creation succeeded but upload failed, check CherrySub status and the new key ID before rerunning: the first upload may have committed, and a private key may not be downloadable again.

Disconnecting stops the app's server credential use without deleting historical transactions or access evidence. Reconnect with the correct app identity and credential. [Troubleshooting](#troubleshooting).

<a id="app-store--import-the-apple-product-catalog"></a>

## Import the Apple product catalog

Open **Products → Import from store → App Store**. Product listing requires an App Store Connect API key from **Users and Access → Integrations → App Store Connect API**. Upload its `.p8`, Issuer ID, and Key ID in this screen. It is stored encrypted separately from the In-App Purchase key used by server history operations.

Save the key, load the store products, select subscriptions or one-time products, and preview the additions. Saving checks the key format; loading checks actual provider access and the bundle/numeric app identity. Existing products are kept. Access mapping is optional, and imported products do not create purchase evidence. Use **Change key** to replace catalog credentials. Disconnecting Apple removes both its managed connection and catalog key. [Migration guide](#migration).


---

<a id="google-play"></a>

# Connect Google Play

Google setup combines a Play-enabled service account for authoritative purchase reads and a separate authenticated Pub/Sub push path for real-time developer notifications (RTDN). The Play credential and the Pub/Sub push identity have different jobs.

<a id="google-play--before-you-start"></a>

## Before you start

Create the app and products in Play Console and enter the exact package name in CherrySub. Prepare a Google Cloud project. The setup operator needs permission to enable APIs and create service accounts/keys in that project, plus the Play authority to manage users and app grants.

For automation, the current CLI requires an organization-provided **Desktop app OAuth client**. A CherrySub-owned verified Google OAuth client is not bundled. Download the Desktop client JSON from [Google Cloud credentials](https://console.cloud.google.com/apis/credentials), configure its audience/test users as needed, and keep it separate from the service-account JSON the command will generate.

<a id="google-play--automated-credential-provisioning"></a>

## Automated credential provisioning

```sh
cherrysub setup google APP_UUID --project PROJECT_UUID \
  --oauth-client ./google-desktop-client.json \
  --cloud-project my-cloud-project \
  --developer-account 1234567890123456789
```

`--project` is the CherrySub project. `--cloud-project` is Google's project ID. The Play developer account ID is the number after `/developers/` in the Play Console URL; the CLI also accepts that full URL.

After local browser authorization with PKCE and a loopback callback, the CLI displays the target project, account, package, and service-account email. On confirmation it enables IAM and Android Publisher APIs, creates/reuses an app-specific `cherrysub-*` service account, grants package access, creates a key in memory, and uploads it to CherrySub.

The package permissions are app information, financial data, and order/subscription management. Existing permissions are retained. Temporary account-wide bootstrap access created by the flow is removed, and cleanup failure is reported. Organization policies blocking key creation are not changed automatically.

<a id="google-play--upload-an-existing-service-account"></a>

## Upload an existing service account

Enable Android Publisher access and grant the service-account email the required app permissions in Play Console. In Google Cloud, create a JSON key for that service account. In CherrySub, use **Integrations → Google Play → Upload credential**.

Choose the service-account JSON, up to 64 KiB. CherrySub previews its account email before saving. An OAuth client JSON is a different file and is rejected by this uploader. The manual-paste alternative is available under **Paste JSON instead**.

The service-account JSON must name `type: service_account`, include `client_email` and `private_key`, and use `https://oauth2.googleapis.com/token` as `token_uri`. Store permission changes may take time to become usable; test an authoritative purchase read after setup.

<a id="google-play--configure-rtdn-separately"></a>

## Configure RTDN separately

Credential provisioning does not create RTDN infrastructure in this preview. The installation operator must supply an authenticated push path and the Play team must connect its topic.

1. Create a Pub/Sub topic for the intended environment and grant `google-play-developer-notifications@system.gserviceaccount.com` Pub/Sub Publisher on that topic.
2. Create a push subscription with the Pub/Sub message wrapper enabled. Its `messageId` is used for delivery idempotency.
3. Use this staging endpoint and exact OIDC audience: `https://api-staging.cherrysub.com/ingest/google/rtdn`.
4. Configure a user-managed push service-account identity. The installation's `GOOGLE_PUBSUB_SERVICE_ACCOUNT` must match the expected OIDC email; `GOOGLE_PUBSUB_AUDIENCE` must match the exact audience.
5. Grant the Pub/Sub service agent the required token-creation authority for that push identity and configure retry/dead-letter handling.
6. In the Play app's monetization setup, enter the fully qualified topic name `projects/PROJECT_ID/topics/TOPIC_ID`, save, and send a test notification.

Use [Google's RTDN setup guide](https://developer.android.com/google/play/billing/getting-ready#configure-rtdn) for the current console controls. Operators should follow [self-hosting](#self-hosting) for the server configuration and a dedicated environment's retry/dead-letter settings.

The current ingress has a deployment-level expected Pub/Sub audience and push identity, not an independently configurable push identity per dashboard app. Coordinate that identity with the installation operator rather than assuming each newly generated Play service account can authenticate Pub/Sub pushes automatically.

<a id="google-play--check-purchase-verification"></a>

## Check purchase verification

A test RTDN is accepted as a notification event and does not prove purchase-token read access. Submit a real test purchase through your SDK. The backend fetches the authoritative subscription or one-time-product state and attributes its actual environment.

Use `product_type: subscription` for subscriptions, including prepaid plans, or `one_time_product` for in-app products. Pending purchases are not completed as paid purchases. The app's acknowledge/consume policy runs only after backend acceptance.

<a id="google-play--recover-a-failed-setup"></a>

## Recover a failed setup

For permission errors, check the selected Cloud project, Play developer account, package grant, and signed-in operator. For key-creation policy failures, use your organization's approved credential process. For an upload timeout, check masked connection status and the reported key ID before retrying or revoking an unused new key.

A missing Pub/Sub configuration fails notification ingress closed. A failed authoritative Play read does not become a successful purchase based only on the notification integer. [CLI](#cli) · [Troubleshooting](#troubleshooting).

<a id="google-play--import-the-google-product-catalog"></a>

## Import the Google product catalog

**Products → Import from store → Google Play** uses this app's connected service account. It reads subscriptions and modern one-time products through Android Publisher, preserving subscription base plan IDs. The account needs access to the exact Play app and its product catalog. Product listing performs no Play configuration writes.

Wait for all pages to load, search and select products, then preview the import. Leave access unmapped or optionally attach one existing level with a store-managed mapping. Existing product entries remain unchanged. Product sets and native offer-token selection are separate steps; an imported Google package can preserve its exact base plan in `base_plan_id`. Pricing, regional availability, purchase options, and offers remain Play configuration. [Products](#products) · [Migration](#migration).


---

<a id="cli"></a>

# CherrySub CLI

CherrySub CLI signs you in, chooses your project and app, and provisions store
credentials locally. It uses the same account permissions as the dashboard.
The preview defaults to **staging** (`https://api-staging.cherrysub.com`).

<a id="cli--install"></a>

## Install

macOS / Linux (arm64 or amd64), without Go or Node.js:

```sh
curl -fsSL https://api-staging.cherrysub.com/cli/install.sh -o /tmp/cherrysub-install.sh
sh /tmp/cherrysub-install.sh
export PATH="$HOME/.local/bin:$PATH"
cherrysub --version
```

The installer verifies the archive's SHA-256 checksum before installing. It
requires no sudo. `CHERRYSUB_INSTALL_DIR` changes the destination. Add that
directory to your shell's PATH for future terminal sessions.

Windows: download [amd64](https://api-staging.cherrysub.com/cli/downloads/cherrysub_windows_amd64.zip)
or [arm64](https://api-staging.cherrysub.com/cli/downloads/cherrysub_windows_arm64.zip),
verify against [checksums.txt](https://api-staging.cherrysub.com/cli/downloads/checksums.txt),
and extract `cherrysub.exe` to a directory on PATH.

Repository contributors can run `go install ./cmd/cherrysub` from the checkout.
`python3 scripts/build-cli.py` produces all six platform archives and checksums.

<a id="cli--first-connection"></a>

## First connection

```sh
cherrysub auth login
cherrysub setup apple
# or:
cherrysub setup google --oauth-client ./google-desktop-client.json
```

The CLI prompts for missing project/app selections and remembers your choice.
If you have several projects, `--project UUID` selects one explicitly. Pass the
app UUID after `apple` or `google` to connect a particular app. The dashboard's
Integrations page provides the command with both identifiers filled in.

An existing app credential is kept unless you pass `--rotate`. Shared deployment
credentials can be replaced with an app credential. Old store keys are **never
automatically revoked**, including during rotation.

```sh
cherrysub projects list
cherrysub projects use PROJECT_UUID
cherrysub apps list
cherrysub apps use APP_UUID
cherrysub setup apple APP_UUID --dry-run --json
cherrysub stores status APP_UUID --json
```

`--dry-run` checks CherrySub access and prints a plan. It does not sign in to a
store or create/upload keys. It can update the CLI session's selected project.

<a id="cli--apple-automation"></a>

## Apple automation

`setup apple` performs local Apple Account sign-in, trusted-device or SMS 2FA,
team selection, a bundle-ID lookup, and In-App Purchase key access checks. After
you confirm the app and team, it creates and downloads an In-App Purchase key
and sends it directly to CherrySub. Passwords and Apple session cookies are not
stored. The key exists only in process memory before upload.

Use an Apple role allowed to create In-App Purchase keys. The app must already
exist in App Store Connect with the bundle ID configured in CherrySub. Account
agreements or Apple's key limits may need attention in App Store Connect.
This uses Apple's web-session endpoints, which can change independently of the
public API. File import is available if interactive sign-in is unavailable.

```sh
cherrysub setup apple APP_UUID --sms
cherrysub setup apple APP_UUID --team-id 123456 --rotate
cherrysub setup apple APP_UUID --key-file ./SubscriptionKey_ABC123DEFG.p8 --issuer-id ISSUER_UUID
```

Key ID is detected from `SubscriptionKey_ID.p8` (also `AuthKey_ID.p8`) or supplied
with `--key-id`. Use an **In-App Purchase** key, not an App Store Connect API key.
The key format alone cannot distinguish them.

<a id="cli--google-automation"></a>

## Google automation

Google browser authorization needs a **Desktop app OAuth client** belonging to
your organization. A CherrySub-owned verified OAuth client is not bundled in
this preview. Create one at [Google Cloud credentials](https://console.cloud.google.com/apis/credentials),
configure its audience/test users as required by Google, download the client
JSON, and supply `--oauth-client`. This is an OAuth client file, distinct from
the service-account key the setup command will create.

```sh
cherrysub setup google APP_UUID \
  --oauth-client ./google-desktop-client.json \
  --cloud-project my-google-project \
  --developer-account https://play.google.com/console/u/0/developers/1234567890123456789/app-list
```

Sign in as someone who can enable services, create service accounts/keys in that
Cloud project, and manage Play users. The command:

1. Uses browser OAuth with PKCE and a loopback callback.
2. Enables IAM and Android Publisher APIs on the selected existing Cloud project.
3. Creates or reuses an app-specific `cherrysub-*` service account.
4. Grants the package's app information, financial data, and order/subscription permissions.
5. Creates a JSON key in memory and uploads it to CherrySub.

The temporary Play account-level bootstrap permission is removed even when
package access fails; a cleanup failure is reported. Existing users' permissions
are preserved. Organization policies blocking key creation are surfaced; the
CLI does not change those policies. Google OAuth tokens never go to CherrySub.

Existing service-account key:

```sh
cherrysub setup google APP_UUID --service-account ./service-account.json
```

<a id="cli--scripting-and-profiles"></a>

## Scripting and profiles

Use `--json --no-input --yes` with explicit identifiers for unattended setup.
Errors go to stderr and return a nonzero exit status. Store credentials are
never included in JSON output. `auth login --password-stdin --email EMAIL`
accepts a password via stdin without a password command-line argument.

Supported secret environment variables:

- `CHERRYSUB_PASSWORD`: CherrySub sign-in only.
- `CHERRYSUB_APPLE_PASSWORD`, `CHERRYSUB_APPLE_2FA_CODE`: local Apple sign-in.
- `CHERRYSUB_GOOGLE_ACCESS_TOKEN`: existing user OAuth access token with
  `cloud-platform` and `androidpublisher` scopes; alternative to browser sign-in.
- `CHERRYSUB_GOOGLE_CLIENT_ID`, `CHERRYSUB_GOOGLE_CLIENT_SECRET`: alternative to
  the Desktop OAuth client JSON file.

`CHERRYSUB_EMAIL` and `CHERRYSUB_APPLE_ID` supply account emails. Avoid shell
tracing and putting secret values directly into shell history.

Sessions are private files (0600 on macOS/Linux) in the OS user config directory
under `cherrysub/`. The containing directory is 0700. On Windows use your private
user profile directory with its normal account ACLs. `CHERRYSUB_CONFIG_DIR`
overrides the directory. Passwords/store credentials are never saved there.

```sh
cherrysub --profile self-hosted --api-url https://api.example.com auth login
cherrysub --profile self-hosted setup apple APP_UUID
cherrysub auth status
cherrysub auth logout
```

A signed-in profile cannot be retargeted to another API. Use another profile.
The CLI rejects credential-bearing redirects and non-HTTPS origins (except
literal loopback IPs for development). Profile locks serialize session-scoped
project selection; concurrent jobs should sign in to separate profiles. After
a hard crash, remove the reported `.lock` file only when no command is running.

Create new resources:

```sh
cherrysub projects create --name "My project"
cherrysub apps create --name "My app" --bundle-id com.example.app --keys-out ./sdk-keys.json
```

App creation saves the one-time SDK keys to a new private file. It never
overwrites an existing file or prints the secret key.

<a id="cli--after-setup-recovery"></a>

## After setup / recovery

`credential_saved` means CherrySub accepted and encrypted the credential. It
does not mean a live purchase or all store permissions have been verified.
Notifications (Apple server notifications / Google RTDN) remain a separate
step. Use the dashboard setup guide and make a sandbox purchase to verify the
complete flow. New Google permissions may need time to propagate.

If upload fails after key creation, the CLI reports the new key's ID, retains
old keys, and exits nonzero. Check `stores status` before retrying because a
network timeout can occur after the server commits. Revoke an unused new key
in the store console if needed. Newly created Apple keys cannot be downloaded
again. CLI tests use local Apple/Google/API fixtures; live account provisioning
is performed only when an operator runs setup with their own account.

Each CLI archive includes `THIRD_PARTY_NOTICES.md` for the MIT-licensed
[RevenueCat CLI](https://github.com/RevenueCat/cli) components adapted for local
store provisioning.

<a id="cli--catalog-and-purchase-migration"></a>

## Catalog and purchase migration

The CLI reads RevenueCat configuration locally, imports portable CSV/JSON files, and lists connected App Store or Google Play products. Every catalog command previews by default; use `--apply` to apply a reviewed plan. A verified owner/admin session and the selected CherrySub project scope catalog operations.

```sh
cherrysub migrate revenuecat APP_ID --source-project RC_PROJECT_ID
cherrysub migrate revenuecat APP_ID --source-project RC_PROJECT_ID --apply
cherrysub migrate store google APP_ID --product premium,credits --apply
cherrysub migrate file ./catalog.json APP_ID --apply
cherrysub migrate history APP_ID
```

RevenueCat needs a V2 secret key with project-configuration read permissions. Enter it at the masked prompt or use `CHERRYSUB_REVENUECAT_API_KEY` in automation. `--source-app` resolves ambiguous source-app matches. `--output` saves a new private JSON catalog that can be reviewed in the dashboard. `--activate-current` explicitly selects the source's current offering; leaving it out preserves the destination selection.

The distinct purchase command uses a CherrySub app secret and normal verified receipt ingestion. `CHERRYSUB_APP_SECRET` selects the app regardless of the dashboard project/app selection. It does not use the hosted session cookie. Preview checks the file locally; applying verifies each purchase server-side and may emit normal webhooks.

```sh
cherrysub migrate purchases --template > purchases.csv
cherrysub migrate purchases ./purchases.csv
cherrysub migrate purchases ./purchases.csv --apply --checkpoint ./progress.jsonl
cherrysub migrate purchases ./purchases.csv --apply --resume --checkpoint ./progress.jsonl
```

Use real StoreKit 2 JWS transactions or Google purchase tokens and retain exact App User IDs. Checkpoints bind the file, API origin, and app secret and contain no plaintext receipt/token or user identity. Completed records are skipped on resume. Do not modify the input file to fix a rejected row while reusing its checkpoint; use a separate corrected file and checkpoint. The [complete migration guide](#migration) covers source formats, Google base-plan policy, limits, failure recovery, and cutover validation.


---

<a id="products"></a>

# Products

A product is a purchasable item defined by Apple or Google. CherrySub registers the store identifier and connects observed purchases to access rules and product presentations. It does not replace App Store Connect or Play Console as the source of store product configuration.

<a id="products--create-the-store-product-first"></a>

## Create the store product first

Define the subscription or one-time product in the store, including its product ID, pricing, duration, offers, and availability. Google base plans and offers remain storefront configuration. Use the exact store product ID in CherrySub; matching by display name is not enough.

Store products can be registered in CherrySub before their first purchase. Create an access level first, then use **Products → Register product**. Choose the platform, the exact product ID, the access level it grants, and its grant mode. This form registers the product through an explicit product access mapping. If no access level exists, the dialog takes you to Access levels first. Where store search is available, select the intended result; otherwise enter the exact ID.

<a id="products--import-products-from-a-store"></a>

## Import products from a store

Use **Products → Import from store** to load the App Store or Google Play catalog. Apple needs a separate App Store Connect API key; Google uses the connected Play service account. Select products, optionally choose an access level, and preview the changes before applying them. Subscriptions and one-time products are supported, including Google's modern one-time-product API.

Imported products can remain unmapped and can exist before any purchase. Their names, types, and Google base plan IDs appear as catalog metadata. Existing matching entries are kept; imports never overwrite store prices or synthesize transaction history. Configure mappings and purchase presentation as separate next steps.

The CLI supports `cherrysub migrate store google APP_ID --product premium,credits`, with `--apply` after review. RevenueCat and CSV/JSON catalog imports use the same preview and conflict rules. See the [migration guide](#migration) for limits, credentials, templates, and purchase migration.

<a id="products--connect-the-two-catalog-relationships"></a>

## Connect the two catalog relationships

A useful product participates in two independent relationships:

1. A [product access mapping](#access-levels) determines which access level it grants after verification.
2. A [package in a product set](#product-sets) determines whether and where your app presents it for purchase.

A SKU can be in a product set without granting any access. It can also grant access without appearing in the current product set, such as a historical product you continue to honor. Configure both relationships deliberately.

<a id="products--read-product-details"></a>

## Read product details

Open a product to inspect its mapped access levels, store facts, transactions, and subscription lineages. Monetary values remain separated by store currency. Missing accounting information is not a zero-priced sale.

A product identifier is scoped by both app and platform. The same ID can exist in another app, so detail links carry the app context. An unscoped ambiguous product request is rejected rather than returning another app's record.

<a id="products--subscription-versus-one-time-products"></a>

## Subscription versus one-time products

Store vocabulary matters. Apple consumables, non-consumables, and non-renewing subscriptions are non-subscription purchases. Google uses `one_time_product` for in-app products; its prepaid subscription plans remain subscriptions even though they do not auto-renew.

For Google receipt submission, choose the explicit API `product_type`: `subscription` or `one_time_product`. The SDK's Billing adapter translates store library values into that contract. Do not classify every non-auto-renewing purchase as a one-time product.

<a id="products--change-a-catalog-without-rewriting-history"></a>

## Change a catalog without rewriting history

Update store pricing and availability in the store. Update CherrySub presentation in Product sets and access rules in Product access mappings. Access mapping versions start at a ledger boundary; replay uses the mapping version that existed for the original source event.

Removing a product from the current product set does not cancel an existing subscription, revoke its access mapping, or delete its transactions. Removing a mapping does not refund a purchase. For a migration, first confirm how existing customers will retain access on their next authoritative observation. [Migration guide](#migration).

<a id="products--verify-a-new-product"></a>

## Verify a new product

Make a sandbox purchase, verify the exact app/platform/product in Transactions, open the customer, and check the intended access level. Then fetch the current product set from the SDK and confirm its package resolves to the expected storefront product and localized price.

If the transaction exists but the feature remains locked, inspect the mapping, grant mode, expiry/revocation, and customer ownership before changing the purchase UI.


---

<a id="access-levels"></a>

# Access levels and mappings

An access level is a stable capability your application checks, such as `pro`. Store SKU names can differ across platforms while both map to the same access level. This keeps application feature checks independent of product naming and pricing changes.

<a id="access-levels--create-an-access-level"></a>

## Create an access level

Open **Access levels → New access level**, choose an immutable lookup key, and enter a display name. The lookup key is the value used by application code. A display-name edit is not a rename of that key.

Then add a mapping from the appropriate app/platform/product to the access level. A mapping is explicit: a purchase of an unmapped SKU does not automatically grant a similarly named level.

<a id="access-levels--choose-the-grant-mode"></a>

## Choose the grant mode

| Mode | Behavior | Typical use |
| --- | --- | --- |
| `store_managed` | Access follows verified store state and expiry | A recurring subscription |
| `lifetime` | Access has no scheduled expiry, but verified revocation/refund still matters | A permanent unlock |

Lifetime is a catalog rule, not a manual grant to a chosen customer. The current dashboard does not provide an arbitrary customer-access override flow.

<a id="access-levels--read-access-from-your-app"></a>

## Read access from your app

The subscriber API returns an access-level map keyed by the lookup key:

```json
{
  "app_user_id": "user-123",
  "customer_id": "22222222-2222-4222-8222-222222222222",
  "access_levels": {
    "pro": {
      "active": true,
      "expires_at": "2026-10-09T10:00:00Z",
      "platform": "apple",
      "product_id": "pro.monthly",
      "last_verified_at": "2026-09-09T10:00:00Z",
      "event_id": "123"
    }
  }
}
```

The SDK exposes the corresponding language-native `accessLevels` map. Check its `active` value. A missing key does not grant access. `expires_at` can be absent for an access grant without scheduled expiry. This is an illustrative response, not a live entitlement certificate.

<a id="access-levels--how-multiple-purchases-combine"></a>

## How multiple purchases combine

CherrySub maintains per-store-identity, per-product grants and resolves their union. One expired Apple subscription cannot mask a different active Google purchase or a lifetime unlock. An observation replaces only the affected identity's grant contribution.

Cancellation of auto-renewal is not immediate expiration. Current access is evaluated against persisted expiry at read time, so it becomes inactive at the boundary even without a timer event. Do not assume a webhook will arrive at that exact wall-clock instant.

<a id="access-levels--mapping-changes-are-versioned"></a>

## Mapping changes are versioned

Creating, changing, or ending a mapping establishes a safe event boundary. Old source events retain the mapping version that applied when they were observed. New authoritative observations use the applicable newer version.

This means changing a mapping does not silently reprocess every past transaction into a different access history. Existing purchases may reflect the rule change on their next new authoritative observation. Validate migration behavior with a representative sandbox customer before broad catalog changes.

<a id="access-levels--inspect-and-troubleshoot"></a>

## Inspect and troubleshoot

Access-level details show mapped products and customer grant rows. A mapping can exist while no customer currently has active access. Inspect the customer's current access, transaction expiry/revocation, product ID, app scope, and ownership.

Use [Customers](#customers) to trace a specific case. Use the [runtime API reference](#api-reference) for access-level creation, display-name updates, archival, and mapping changes available to trusted server integrations. Archival preserves historical/replay records; it is not a deletion of evidence.


---

<a id="product-sets"></a>

# Product sets and packages

A product set is the ordered purchase presentation for an app. A package is a slot in that set that names a store product. The application reads the current set, loads those products from StoreKit or Google Play, and displays the store's localized price and offer information.

<a id="product-sets--create-a-product-set"></a>

## Create a product set

Open **Product sets → New product set**. Enter a stable identifier such as `default` and a display name. Open the new set and add its packages.

| Package field | Meaning |
| --- | --- |
| Identifier | Unique slot within the set, such as `monthly_ios` |
| Package type | `custom`, `lifetime`, `annual`, `six_month`, `three_month`, `two_month`, `monthly`, or `weekly` |
| Platform | `apple` or `google` |
| Product ID | The exact SKU for that app and store |
| Position | Unique integer from 0 to 1000; determines ascending order |

Identifiers and positions must be unique within a set, including across stores. For a cross-platform set, use distinct package identifiers such as `monthly_ios` and `monthly_android` rather than duplicating a slot name.

<a id="product-sets--make-a-set-current"></a>

## Make a set current

Mark the intended set as the app's current product set. There is one current presentation for the app, not one per user. Editing or selecting it changes what subsequent runtime reads return. Existing purchases and access mappings are independent of this selection.

The runtime read requires the publishable key plus customer token for a public client, or a secret key on a trusted server:

```sh
curl -fsS "$CHERRYSUB_API_URL/v1/product-sets/current" \
  -H "Authorization: Bearer $CHERRYSUB_PUBLISHABLE_KEY" \
  -H "X-IAP-Customer-Token: $CHERRYSUB_CUSTOMER_TOKEN"
```

The body contains `product_set`, either an object with ordered `packages` or explicit `null`. A not-yet-configured app is not a transport error.

<a id="product-sets--use-the-set-in-your-storefront"></a>

## Use the set in your storefront

1. Fetch the current set after authenticating your application user.
2. Keep packages for the platform being shown, in the returned order.
3. Load those product IDs using StoreKit or Google Play Billing.
4. Display storefront-provided title, localized price, eligibility, and offers.
5. On purchase, submit the verified purchase evidence with the same authenticated App User ID.

Package type is presentation metadata; it does not create the store's billing period or base plan. The set does not contain a trustworthy locally computed price or a hosted paywall layout. Your application owns the purchase screen and offer choice.

<a id="product-sets--empty-invalid-and-stale-presentations"></a>

## Empty, invalid, and stale presentations

If `product_set` is `null`, show a deliberate unavailable/empty state and investigate configuration. If the current set has no package for the device's platform, do not present a different store's product. If the store cannot load a configured SKU, verify app identity, product availability, agreements, and tester eligibility.

The SDKs fail closed on malformed, duplicated, or out-of-order package data. They do not silently reorder invalid input or substitute another set. Current product-set responses use `Cache-Control: no-store`; apply an explicit refresh strategy if your app keeps its own UI cache.

<a id="product-sets--editing-and-removal"></a>

## Editing and removal

Add/remove packages and update the set's name/current selection from its detail page. Review the result on both platforms before release. Removing a package or a product set does not cancel subscriptions or remove access granted by separate mappings.

The current version does not include audience-targeted sets, remote paywall layout editing, or A/B assignment. Use [release status](#limits-and-release-status) to distinguish available runtime presentation from planned features.

<a id="product-sets--imported-packages-and-google-base-plans"></a>

## Imported packages and Google base plans

RevenueCat offerings can be imported as product sets, with packages split into one entry per store. The import report shows identifier transformations, such as `$rc_monthly` to `rc_monthly_google`. An existing current set stays selected unless activation is requested explicitly.

Imported Google packages can carry `base_plan_id`. The SDK exposes it as `basePlanId` (Kotlin, Dart, TypeScript), `basePlanID` (Swift), or `BasePlanID` (Go). It is optional and absent for Apple packages. Preserve it when resolving the Google subscription offer token; the subscription product ID alone can identify several durations or plans. An imported product's base plan list is catalog metadata and is not a localized price list.

When using a native Google Billing `ProductDetails`, select a subscription offer whose `basePlanId` matches the imported package, then apply your application's offer eligibility policy to choose the token. Do not pick the first offer across all base plans. The current manual package form has no base-plan field; use a portable JSON import for an exact Google base-plan package. CherrySub access mappings remain product-level; imports that would combine different base-plan entitlements are blocked. [Migration](#migration).


---

<a id="authentication"></a>

# API keys and customer tokens

CherrySub separates the hosted person's session, the application's server authority, and the purchasing customer's short-lived client authority. Choose the correct credential for each boundary.

<a id="authentication--credential-reference"></a>

## Credential reference

| Credential | Where it belongs | What it authorizes |
| --- | --- | --- |
| `JSESSIONID` | Dashboard browser or a private CLI profile | Hosted account/project operations, subject to membership and role |
| `iap_pk_` | Mobile/web client configuration | Identifies the app; public runtime reads/submissions also require a customer token |
| `iap_ct_` | Client memory, supplied by your authenticated backend | The specific app and App User ID until expiry/revocation |
| `iap_sk_` | Your trusted application backend | App-scoped server operations and customer-token mint/revoke |
| Root admin bearer key | Service operator environment | `/admin/v1`; not a hosted customer's project credential |
| Apple/Google store key | Managed encrypted storage or operator credential configuration | Calls to the respective store; not a CherrySub API bearer key |
| Webhook signing secret | Your webhook receiver | Verifies outbound delivery signatures |

The CLI uses hosted account sessions. It does not require root admin keys. A publishable key is intentionally distributable, but it does not let a client mint tokens, edit the catalog, or manage webhooks.

<a id="authentication--create-and-rotate-app-keys"></a>

## Create and rotate app keys

Open **API keys** for the selected app. Create a key, choose Secret or Publishable, and label its purpose. Copy the one-time plaintext result into the correct deployment configuration. Lists show only safe hints and metadata.

For rotation, create the replacement first, update the consuming server or application, verify it works, then revoke the old key. A revoked key stops authenticating. Do not delete the last working key before clients have a replacement. A lost secret cannot be read back from CherrySub.

<a id="authentication--implement-an-authenticated-token-broker"></a>

## Implement an authenticated token broker

Your backend obtains the App User ID from its verified session or JWT middleware. Never let a client request a token for an arbitrary `app_user_id` merely by placing it in JSON.

On the trusted backend, call:

```sh
curl -fsS "$CHERRYSUB_API_URL/v1/client-tokens" \
  -H "Authorization: Bearer $CHERRYSUB_SECRET_KEY" \
  -H 'Content-Type: application/json' \
  --data '{"app_user_id":"user-123","ttl_seconds":3600}'
```

The result includes `id`, `app_user_id`, `hint`, `expires_at`, and one-time `token`. Return only `{ "token": "iap_ct_...", "expires_at": "..." }` to the client. Keep the token ID server-side if logout should revoke it with `DELETE /v1/client-tokens/{token_id}`. The default TTL is one hour, with an allowed range of 300–86400 seconds.

Use `Cache-Control: no-store` on your broker response. The [Go SDK](#go) implements this trusted API handoff; you can also use the HTTP contract from another backend language.

<a id="authentication--send-a-public-runtime-request"></a>

## Send a public runtime request

```sh
curl -fsS "$CHERRYSUB_API_URL/v1/subscribers/user-123" \
  -H "Authorization: Bearer $CHERRYSUB_PUBLISHABLE_KEY" \
  -H "X-IAP-Customer-Token: $CHERRYSUB_CUSTOMER_TOKEN"
```

The customer token must belong to that app. Its subject must match the subscriber path or receipt's `app_user_id`. These checks run before store I/O or ownership changes. URL-encode user IDs when constructing paths. Keep `event_id` strings as strings in clients; do not round them through JavaScript numbers.

<a id="authentication--refresh-and-logout"></a>

## Refresh and logout

The SDKs' optional in-memory token providers cache by user and expiry, refresh 30 seconds early by default, coalesce concurrent refreshes, and prevent an old in-flight refresh from repopulating an invalidated user's cache. On a 401, a client forces token refresh and retries the request once.

On application logout, invalidate that user's provider cache and clear application-owned access/purchase UI state. If your policy requires server-side invalidation, revoke the minted customer token as well. Local cache invalidation alone is not server revocation.

Do not fall back to a server secret in the client after a token error. For repeated 401s, check token expiry, app/deployment mismatch, subject mismatch, key revocation, and the broker's authenticated identity. For 403, verify that the chosen API operation is permitted by the key kind.


---

<a id="customer-identity"></a>

# Customer identity and ownership

Your application chooses an App User ID and authenticates it. CherrySub stores an app-scoped customer record and its aliases, purchase ownership, and resolved access. Dashboard accounts and project members are a different identity system.

<a id="customer-identity--choose-a-stable-app-user-id"></a>

## Choose a stable App User ID

Use an opaque, durable identifier from your user database. The API accepts a non-empty UTF-8 string up to 512 bytes, excludes control characters, and trims surrounding whitespace. Avoid using an email address that can change, a device advertising identifier, or a display name.

The same text under another CherrySub app is not automatically the same customer. Keep the App User ID consistent between your authentication middleware, token broker, receipt submission, and subscriber read. SDK path construction supports Unicode and reserved characters; custom HTTP clients must URL-encode the path segment.

<a id="customer-identity--establish-the-customer-through-verified-evidence"></a>

## Establish the customer through verified evidence

Minting a customer token authenticates the intended subject; it does not fabricate a paid customer or purchase. Before a customer alias exists, a subscriber read can return 404. A valid purchase submission establishes/updates the customer and verified purchase binding.

Do not grant premium access because a token was minted or a storefront popup succeeded. Check the returned subscriber access after verification.

<a id="customer-identity--purchase-ownership-and-restore"></a>

## Purchase ownership and restore

Customer aliases and purchase ownership are separate. A verified restore can transfer a specific purchase binding to another authenticated customer. That transfer does not implicitly merge or alias their entire customer identities. An older observation cannot reverse a newer transfer.

Design the restore UX around your application's account policy: show which application account is signed in, explain a restore action, and submit the store evidence using a token for that account. A raw request cannot choose the store app from its JSON; the API key selects it.

There is no documented general-purpose merge/alias-management endpoint or dashboard customer-transfer button in this preview. Do not simulate a merge by copying database rows or by trusting a user-supplied customer ID in your token broker.

<a id="customer-identity--switching-accounts-safely"></a>

## Switching accounts safely

1. Stop showing access state cached for the departing user.
2. Invalidate the departing user's in-memory token provider.
3. Revoke server-side customer tokens if your logout policy requires it.
4. Authenticate the new user and obtain a token for that exact App User ID.
5. Fetch the new user's subscriber state and product set.
6. Run an explicit restore only when your UX and account policy call for it.

An in-flight token refresh started before invalidation must not become the new user's token. The supplied cache providers prevent this cache-repopulation race, but application-owned state still needs to be cleared.

<a id="customer-identity--anonymous-use-and-migration"></a>

## Anonymous use and migration

CherrySub does not automatically decide your anonymous-to-identified user policy. If you support anonymous sessions, your backend must authenticate and consistently identify them, and your application must define the transition to a registered account. Do not assume RevenueCat's alias/restore behavior is identical.

For an existing app, preserve your stable user IDs and test representative restores before cutover. Historical imports, arbitrary customer merges, and automatic migration of another provider's aliases are not currently a one-command feature. [Migration](#migration).

<a id="customer-identity--investigate-an-ownership-issue"></a>

## Investigate an ownership issue

Find the transaction under the correct app, inspect its customer, compare the listed App User IDs with your broker's authenticated subject, and inspect the event history around the restore. Check both stores' grant contributions before concluding a user has lost all access. Support notes record investigation context; they do not alter identity or access.


---

<a id="swift"></a>

# Swift and StoreKit 2

The Swift package is `AppVisionIAP` in `sdk/swift`. It supports iOS 15, macOS 12, tvOS 15, watchOS 8, and visionOS 1 or later, and its manifest uses Swift tools 6.0. Public registry/package distribution is not finalized in this preview; add the local package from your approved CherrySub source checkout.

<a id="swift--add-the-package-and-configure-authentication"></a>

## Add the package and configure authentication

In Xcode, add `sdk/swift` as a local Swift package dependency and link the `AppVisionIAP` product. Prepare your [authenticated token broker](#authentication) and the app's publishable key.

In these examples, `sessionAPI` is your application's authenticated networking client and `user.id` is your stable App User ID. They are not CherrySub SDK symbols.

```swift
import AppVisionIAP
import StoreKit

let tokens = try InMemoryCustomerTokenProvider { appUserID in
    let response = try await sessionAPI.createIAPToken(for: appUserID)
    return CustomerToken(token: response.token, expiresAt: response.expiresAt)
}
let iap = try IAPClient(
    baseURL: URL(string: "https://api-staging.cherrysub.com")!,
    publishableKey: "iap_pk_REPLACE_WITH_YOUR_KEY",
    customerTokenProvider: tokens.provider
)
```

Your broker chooses the user from its verified application session and returns token plus expiry. Do not embed `iap_sk_` or store credentials in the app target.

<a id="swift--load-the-current-product-set"></a>

## Load the current product set

```swift
let productSet = try await iap.currentProductSet(appUserID: user.id)
```

A `nil` set means none is selected. Use its ordered Apple packages to load StoreKit products, then show StoreKit's localized price and purchase options. Your app owns the purchase UI and unavailable-product state.

<a id="swift--submit-and-finish-a-purchase"></a>

## Submit and finish a purchase

```swift
if case .success(let verification) = try await product.purchase() {
    try await iap.submitAndFinishAppleTransaction(
        appUserID: user.id,
        verificationResult: verification
    )
}
let subscriber = try await iap.subscriber(appUserID: user.id)
let hasPro = subscriber.accessLevels["pro"]?.active == true
```

Handle StoreKit's pending and user-cancelled outcomes separately in your UI. The helper refuses an unverified transaction and only calls `finish()` after backend acceptance. If backend verification fails, retain the unfinished transaction for a safe retry.

The submit-only StoreKit overload is available when your application owns completion. A raw `transactionJWS` overload supports a custom bridge, but it still requires the actual signed StoreKit transaction JWS.

<a id="swift--recover-unfinished-purchases-and-restore"></a>

## Recover unfinished purchases and restore

Connect the storefront's transaction-update and restore paths to the same authenticated submission policy. A purchase can complete while the app is interrupted; handling only the immediate `product.purchase()` result is insufficient for recovery.

Use the intended application account for restore. A verified restore can transfer that purchase's ownership without merging all customer identities. [Ownership policy](#customer-identity).

<a id="swift--token-lifecycle-and-errors"></a>

## Token lifecycle and errors

The in-memory provider caches per user, refreshes 30 seconds before expiry by default, and coalesces simultaneous refreshes. A 401 triggers one forced refresh and retry. Other failures need your application's retry/error policy.

On logout:

```swift
try await tokens.invalidate(appUserID: user.id)
```

Also clear your application-owned access state and revoke the server-side customer token if required. A subscriber 404 before the first receipt is not active access. Preserve ledger event IDs as strings.

<a id="swift--validate-the-integration"></a>

## Validate the integration

Run the package's `swift test` from `sdk/swift` when changing SDK integration code. The repository's iOS sandbox app demonstrates product-set-driven purchase configuration. Verify a store-backed sandbox purchase, duplicate retry, restore, cancellation/expiry, refund, and account switch using [Testing](#testing). Local StoreKit fixtures alone do not prove Apple server verification.


---

<a id="android"></a>

# Kotlin and Google Play Billing

The JVM core uses the `dev.appvisionos:iap-client:0.5.0` coordinates. The optional Android AAR is `dev.appvisionos:iap-play-billing:0.5.0`, with a Google Play Billing 9.1 adapter. The Billing adapter currently requires Android API 24 or later. These are source/integration coordinates in the current preview, not a promise that a public Maven registry already serves them.

<a id="android--integrate-the-source-packages"></a>

## Integrate the source packages

Use the approved repository checkout. A composite build can substitute the source modules in your application's `settings.gradle.kts`:

```kotlin
includeBuild("../cherrysub/sdk/kotlin") {
    dependencySubstitution {
        substitute(module("dev.appvisionos:iap-client")).using(project(":"))
        substitute(module("dev.appvisionos:iap-play-billing")).using(project(":play-billing"))
    }
}
```

Adjust the relative path to your checkout. Add the corresponding dependencies to your app module and configure Google/Maven repositories as required by your build. The source build uses JVM toolchain 17; follow its checked-in Gradle/Kotlin versions when validating compatibility.

<a id="android--configure-the-client"></a>

## Configure the client

`sessionApi` below is your own authenticated [token broker client](#authentication), not an SDK endpoint that can accept arbitrary user identities.

```kotlin
import dev.appvisionos.iap.*
import kotlin.time.Instant

val tokens = InMemoryCustomerTokenProvider(
    fetcher = CustomerTokenFetcher { appUserId ->
        val response = sessionApi.createIapToken(appUserId)
        CustomerToken(response.token, Instant.parse(response.expiresAt))
    },
)
val iap = IAPClient(
    baseUrl = "https://api-staging.cherrysub.com",
    publishableKey = "iap_pk_REPLACE_WITH_YOUR_KEY",
    customerTokenProvider = tokens,
)
```

The core owns its Ktor/OkHttp transport and can be used without BillingClient. Never distribute an app secret key in the APK.

<a id="android--present-products-and-submit-purchases"></a>

## Present products and submit purchases

Read `iap.currentProductSet(user.id)`, select Google packages in returned order, and ask BillingClient for their product details and offers. Your app selects the purchase offer and starts the billing flow.

```kotlin
import com.android.billingclient.api.BillingClient
import dev.appvisionos.iap.playbilling.GooglePlayPurchaseCompletion
import dev.appvisionos.iap.playbilling.submitAndCompleteGooglePlayPurchase

iap.submitAndCompleteGooglePlayPurchase(
    appUserId = user.id,
    purchase = purchase,
    productType = BillingClient.ProductType.SUBS,
    completion = GooglePlayPurchaseCompletion { verifiedPurchase ->
        storefront.acknowledgeOrConsume(verifiedPurchase)
    },
)
val subscriber = iap.subscriber(user.id)
val hasPro = subscriber.accessLevels["pro"]?.active == true
```

`storefront.acknowledgeOrConsume` is your application-owned completion policy. Subscriptions and non-consumables generally require acknowledgement; consumable behavior is chosen by your app. The adapter invokes your policy only after backend acceptance.

Every state except `PurchaseState.PURCHASED` is rejected before network I/O. The adapter maps BillingClient's `subs`/`inapp` values to `subscription`/`one_time_product`. A raw purchase-token method remains available for custom integration.

<a id="android--recovery-lifecycle-and-errors"></a>

## Recovery, lifecycle, and errors

Handle BillingClient updates and recovery queries, not only the initial UI callback. Leave pending purchases pending. Retry valid unfinished purchases after transient verification failure; do not acknowledge merely because the store UI returned.

The token cache is per user, refreshes early, coalesces requests, and is invalidated with `tokens.invalidate(user.id)`. A 401 triggers one forced refresh; other errors are not universally retried. On teardown, call `iap.close()` for the core client's resources.

<a id="android--test"></a>

## Test

Use the repository's Android sandbox app as a source-integrated example. Verify subscriptions and one-time products with their respective product types, a pending purchase, duplicate submission, offline recovery, refund/revocation, and a restore under a second application account. Confirm the same app/package and sandbox environment in CherrySub. [Google setup](#google-play) · [Test matrix](#testing).


---

<a id="flutter"></a>

# Flutter storefront adapter

The optional `appvision_iap_flutter` package connects Flutter's official `in_app_purchase` plugin to the `appvision_iap_client` Dart core. Its current package version is 0.3.0 and it consumes core 0.5.0. The adapter requires Dart 3.12 and Flutter 3.44 or later according to the checked-in manifest.

<a id="flutter--add-source-dependencies"></a>

## Add source dependencies

Public pub.dev publication is disabled in this preview. With an approved local source checkout, configure your app's paths and override the core package consistently:

```yaml
dependencies:
  flutter:
    sdk: flutter
  in_app_purchase: ^3.3.0
  appvision_iap_flutter:
    path: ../cherrysub/sdk/flutter

dependency_overrides:
  appvision_iap_client:
    path: ../cherrysub/sdk/dart
```

Adjust paths to your project. Run `flutter pub get`. The Flutter adapter re-exports the core SDK; it does not replace your app's billing UI or authenticated backend.

<a id="flutter--configure-the-dart-client"></a>

## Configure the Dart client

Use an `InMemoryCustomerTokenProvider` and `IapClient` as shown in the [Dart guide](#dart), importing:

```dart
import 'package:appvision_iap_flutter/appvision_iap_flutter.dart';
import 'package:in_app_purchase/in_app_purchase.dart';
```

Use `iap_pk_` and your authenticated token broker. Load `iap.currentProductSet(user.id)`, keep packages for the current platform, and resolve their store product details using the purchase plugin.

<a id="flutter--process-the-purchase-stream"></a>

## Process the purchase stream

Subscribe to the plugin's purchase updates for both newly purchased and restored transactions. Handle pending/error/cancel states in your UI without treating them as verified access.

```dart
for (final PurchaseDetails purchase in purchases) {
  if (purchase.status == PurchaseStatus.purchased ||
      purchase.status == PurchaseStatus.restored) {
    await iap.submitAndCompletePurchaseDetails(
      appUserId: user.id,
      purchase: purchase,
      googleProductType: purchase.verificationData.source == 'google_play'
          ? GoogleProductType.subscription
          : null,
    );
  }
}
```

Replace `subscription` with the correct product type for a Google one-time product; this example is a subscription handler.

<a id="flutter--store-specific-evidence-and-completion"></a>

## Store-specific evidence and completion

Apple purchases must be `SK2PurchaseDetails`, whose server verification data is the StoreKit transaction JWS. A StoreKit 1 purchase detail is not an interchangeable payload. Google purchases must be `GooglePlayPurchaseDetails`, whose server data is the purchase token.

Only `purchased` and `restored` are accepted. `completePurchase` runs after the backend accepts the evidence, and failure leaves the transaction unfinished for recovery. Do not independently complete the same purchase earlier in another callback.

After acceptance, fetch the subscriber and update your UI from its access map. Your app owns consumable policy, stream subscription cleanup, and presentation of unavailable products or store errors.

<a id="flutter--authentication-and-account-changes"></a>

## Authentication and account changes

The core provider caches per authenticated user and refreshes before expiry. Invalidate the departing user on logout, clear local access UI, and obtain a new token for the next account before restoring purchases. If your policy requires token revocation, perform it on the backend too.

No SDK customer token is persisted automatically. Do not store an `iap_sk_` or service-account JSON in Flutter assets or compile-time client defines.

<a id="flutter--validate"></a>

## Validate

Run the adapter's `flutter test` and the core Dart tests when modifying source integration. Test Apple StoreKit 2 and Google Play separately, including restored/pending purchases, backend failure before completion, one-time versus subscription product type, and logout during token refresh. [Purchase lifecycle](#receipts-and-restores) · [Testing](#testing).


---

<a id="react-native"></a>

# React Native and TypeScript

The TypeScript package is `@appvisionos/iap-client` 0.5.0. It uses a Fetch-compatible transport and structurally accepts React Native IAP 15/OpenIAP purchase objects without adding the storefront library as a runtime dependency. It can also perform public runtime operations in other modern Fetch environments.

<a id="react-native--integrate-the-source-package"></a>

## Integrate the source package

The package is private in the current preview and is not published to npm. Obtain the approved source checkout, run `npm ci` and `npm run build` inside `sdk/typescript`, then use your project's local-file/workspace package integration with that directory.

Keep the source build's generated `dist` available to the consuming application; the package's exports point there. Add and configure your chosen storefront library separately.

<a id="react-native--configure-the-client-and-token-cache"></a>

## Configure the client and token cache

```ts
import { IAPClient, InMemoryCustomerTokenProvider } from '@appvisionos/iap-client';

const tokens = new InMemoryCustomerTokenProvider({
  fetchToken: async (appUserId) => {
    const response = await sessionApi.createIAPToken({ appUserId });
    return { token: response.token, expiresAt: new Date(response.expires_at) };
  },
});
const iap = new IAPClient({
  baseUrl: 'https://api-staging.cherrysub.com',
  publishableKey: 'iap_pk_REPLACE_WITH_YOUR_KEY',
  customerTokenProvider: tokens.provider,
});
```

`sessionApi` is your application's authenticated backend client. Its backend must derive the App User ID from verified session identity even if your client helper accepts an argument. See [authentication](#authentication).

<a id="react-native--read-the-current-presentation"></a>

## Read the current presentation

```ts
const productSet = await iap.getCurrentProductSet(user.id);
```

Handle `null`, filter packages to the device's store, preserve order, and load the storefront's product details and localized prices. CherrySub does not render a paywall or choose a Google offer for you.

<a id="react-native--submit-and-complete-an-openiap-purchase"></a>

## Submit and complete an OpenIAP purchase

```ts
await iap.submitAndFinishOpenIAPPurchase(
  user.id,
  purchase,
  () => finishTransaction({ purchase, isConsumable: false }),
  'subscription', // Required for Google; omit for Apple.
);
const subscriber = await iap.getSubscriber(user.id);
const hasPro = subscriber.accessLevels.pro?.active === true;
```

`purchase`, `finishTransaction`, and `user` come from your application/storefront integration. Choose `isConsumable` and Google's product type for the actual product; this example uses a non-consumable subscription completion policy.

The adapter accepts only `purchaseState: 'purchased'`, uses the unified `purchaseToken` (StoreKit JWS or Google token), and rejects unsupported stores. It invokes your finish callback only after backend acceptance. Use `submitOpenIAPPurchase` for submit-only handling, or `submitAppleTransaction` / `submitGooglePurchase` with an application-owned bridge.

<a id="react-native--errors-and-lifecycle"></a>

## Errors and lifecycle

A 401 forces token refresh and one retry. Other statuses are not automatically replayed. Handle configuration, validation, transport, and response errors without showing premium access as a fallback.

The in-memory provider coalesces simultaneous refreshes and isolates caches by user. On logout, call `tokens.invalidate(user.id)`, clear your own access cache, and revoke the token on the backend if required. Keep event IDs as decimal strings; they can exceed JavaScript's exact integer range.

Hook both the immediate purchase response and storefront update/recovery events into your verified completion flow. If the app exits before submission finishes, recover the unfinished store transaction on the next session rather than assuming it failed to charge.

<a id="react-native--test"></a>

## Test

The package accepts an injected Fetch implementation for controlled transport tests. Run `npm test` in `sdk/typescript` for the source package's conformance checks, then perform real store-backed sandbox tests in the app. Verify pending/cancelled purchases, network interruption before completion, 401 refresh, invalid product types, restore ownership, and store refunds. [Testing](#testing).


---

<a id="dart"></a>

# Dart core SDK

`appvision_iap_client` 0.5.0 is the public-client core for Dart and Flutter. It uses `package:http` across mobile, desktop, and web and requires Dart 3.8 or later. It accepts signed store evidence from your application's storefront integration; it does not launch a purchase UI.

<a id="dart--add-the-package-from-source"></a>

## Add the package from source

Public registry publication is disabled in this preview. Use the approved source checkout:

```yaml
dependencies:
  appvision_iap_client:
    path: ../cherrysub/sdk/dart
```

Adjust the path, then run `dart pub get` or `flutter pub get`. Flutter apps using the official purchase plugin can use the higher-level [Flutter storefront adapter](#flutter).

<a id="dart--configure-authentication"></a>

## Configure authentication

```dart
import 'package:appvision_iap_client/appvision_iap_client.dart';

final tokens = InMemoryCustomerTokenProvider(
  fetchToken: (appUserId) async {
    final response = await sessionApi.createIapToken(appUserId);
    return CustomerToken(
      token: response.token,
      expiresAt: DateTime.parse(response.expiresAt),
    );
  },
);
final iap = IapClient(
  baseUrl: Uri.parse('https://api-staging.cherrysub.com'),
  publishableKey: 'iap_pk_REPLACE_WITH_YOUR_KEY',
  customerTokenProvider: tokens.provider,
);
```

`sessionApi` is your own authenticated broker client. Adapt its field decoding to the HTTP `expires_at` token envelope. Only a publishable key belongs in this configuration; the broker keeps the app secret on your backend.

<a id="dart--read-presentation-and-submit-evidence"></a>

## Read presentation and submit evidence

```dart
final productSet = await iap.currentProductSet(user.id);

await iap.submitGooglePurchase(
  appUserId: user.id,
  purchaseToken: purchaseToken,
  productType: GoogleProductType.subscription,
);
final subscriber = await iap.subscriber(user.id);
final hasPro = subscriber.accessLevels['pro']?.active == true;
```

This example submits a subscription. Use the one-time-product type for a Google in-app product. Apple submission needs the actual transaction JWS from the store bridge. The core is submit-only: your application must complete the storefront purchase only after success, or use the Flutter adapter to coordinate that ordering.

A null current product set is an explicit configuration state. A subscriber 404 before the first qualifying receipt is not a paid subscription. Your UI must handle both without inventing a fallback purchase or access grant.

<a id="dart--manage-lifetime-and-resources"></a>

## Manage lifetime and resources

The token provider caches per user, refreshes 30 seconds before expiry by default, and coalesces simultaneous refreshes. Invalidating a user prevents an earlier refresh from repopulating that user's cache. The SDK performs one forced refresh after a 401 and does not universally retry other statuses.

```dart
tokens.invalidate(user.id);
iap.close();
```

Close the client when its owning application service is disposed. An internally created HTTP client is closed by `IapClient`; an injected client remains caller-owned. Clear application-owned access state on logout and use server-side token revocation when your policy requires it.

<a id="dart--test-the-core-and-the-storefront-separately"></a>

## Test the core and the storefront separately

Run `dart test` in `sdk/dart` for the core's transport, token lifecycle, product-set, and lossless-ID behavior. Preserve event IDs as strings on Dart Web. Then test your store bridge on the actual target platform; passing a mock HTTP test is not evidence that StoreKit/Play purchase completion is wired correctly.

See [receipts and restores](#receipts-and-restores) for retry/ownership rules and [testing](#testing) for end-to-end acceptance cases.


---

<a id="go"></a>

# Go token broker

The trusted Go package is `github.com/AppVisionOS/cherrysub/sdk/go/iapserver`. Its current SDK version is 0.2.0. It is a server-only bridge: it accepts an `iap_sk_` secret, mints/revokes customer tokens, and reads the current product set. It is not a storefront adapter and must never be compiled into a public app.

<a id="go--integrate-the-package"></a>

## Integrate the package

Use the approved source repository/module distribution. In the preview, source access and package coordinates are not equivalent to a public registry release. Your backend stores the app secret using its normal secret configuration and uses the same CherrySub API deployment as the client.

```go
client, err := iapserver.New(iapserver.Config{
    BaseURL: "https://api-staging.cherrysub.com",
    SecretKey: os.Getenv("CHERRYSUB_SECRET_KEY"),
})
if err != nil {
    // Fail application configuration; never substitute a client key.
}
```

Import `os` and the `iapserver` package in the application file. An empty or wrong-kind key is a configuration error.

<a id="go--implement-an-authenticated-token-endpoint"></a>

## Implement an authenticated token endpoint

The following handler takes your existing verified identity function as a dependency. It does not trust an App User ID from the request body.

```go
package tokenbroker

import (
    "encoding/json"
    "net/http"
    "time"

    "github.com/AppVisionOS/cherrysub/sdk/go/iapserver"
)

func TokenHandler(
    client *iapserver.Client,
    authenticatedUserID func(*http.Request) (string, bool),
) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        if r.Method != http.MethodPost {
            w.Header().Set("Allow", http.MethodPost)
            http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
            return
        }
        userID, ok := authenticatedUserID(r)
        if !ok || userID == "" {
            http.Error(w, "unauthorized", http.StatusUnauthorized)
            return
        }
        created, err := client.CreateCustomerToken(r.Context(), userID, time.Hour)
        if err != nil {
            http.Error(w, "token service unavailable", http.StatusServiceUnavailable)
            return
        }
        w.Header().Set("Cache-Control", "no-store")
        w.Header().Set("Content-Type", "application/json")
        _ = json.NewEncoder(w).Encode(created.CustomerToken())
    }
}
```

Mount this handler behind your application's authentication, CSRF/session policy, and appropriate rate limiting. The identity function must be backed by verified middleware. `CustomerToken()` exposes only `token` and `expires_at` to the public SDK.

<a id="go--revoke-on-logout-when-required"></a>

## Revoke on logout when required

Keep the created token's `ID` in your server-side session state if logout should call `RevokeCustomerToken`. Do not return root credentials or the server secret to support logout. Client cache invalidation and server revocation are different operations.

The mint endpoint defaults to one hour and permits five minutes through 24 hours. Choose a bounded lifetime appropriate to your session policy rather than treating a customer token as a permanent API key.

<a id="go--read-the-current-product-set"></a>

## Read the current product set

```go
productSet, err := client.CurrentProductSet(r.Context())
if err != nil {
    // Handle the typed API or transport error.
}
if productSet == nil {
    // No current presentation has been selected for this app.
}
```

This trusted read needs no customer token. Use a public SDK's customer-token-backed read when returning a presentation directly to an application user.

<a id="go--transport-and-failure-behavior"></a>

## Transport and failure behavior

The default client requires HTTPS outside loopback development, refuses redirects, uses a 15-second timeout, and caps response bodies at 1 MiB. Configurable response limits remain bounded. Non-success responses expose a typed `*iapserver.APIError` with status, safe message, and `RetryAfter`.

The SDK does not automatically retry non-idempotent token minting. A failed response can represent an uncertain outcome, so do not create an unbounded retry loop. The client-safe SDKs handle their own one-shot 401 token refresh independently.

Run `go test ./sdk/go/...` from the source checkout and test your broker with missing sessions, forged requested user IDs, mismatched app deployments, expired tokens, and logout. [Authentication](#authentication) · [API reference](#api-reference).


---

<a id="receipts-and-restores"></a>

# Purchases, renewals, and restores

The receipt API accepts store evidence for an authenticated app and customer. It is the point where a storefront purchase becomes a verified CherrySub observation. Notification delivery and reconciliation complement this client path; neither allows an unverified client payload to grant access.

<a id="receipts-and-restores--receipt-request-contract"></a>

## Receipt request contract

Send `POST /v1/receipts` with an app secret on a trusted backend, or a publishable key and customer token on a public client.

```json
{
  "platform": "apple",
  "app_user_id": "user-123",
  "store_payload": "STOREKIT_TRANSACTION_JWS"
}
```

```json
{
  "platform": "google",
  "app_user_id": "user-123",
  "product_type": "subscription",
  "store_payload": "GOOGLE_PURCHASE_TOKEN"
}
```

Apple uses the StoreKit transaction's JWS, not an old receipt blob or a locally generated test string. Google requires `product_type` to be `subscription` or `one_time_product`; its token is authoritatively read through Android Publisher. Unknown fields are rejected. The request-body ceiling is 256 KiB.

Success returns `{ "event_id": "123", "deduplicated": false }`. The event ID is a decimal string. A duplicate can return `deduplicated: true`; that is still acceptance, not a second sale.

<a id="receipts-and-restores--complete-the-store-purchase-after-acceptance"></a>

## Complete the store purchase after acceptance

Use the SDK's submit-and-finish/complete helper, or reproduce its ordering explicitly: check the store purchase state, submit evidence, await backend acceptance, then finish, acknowledge, consume, or complete as appropriate.

Pending, cancelled, unverified, or unknown purchases must not be sent as successful purchases. Your app owns the choice between acknowledging and consuming a Google product and its offer/purchase UI. A server failure must leave the store transaction available for a safe retry.

<a id="receipts-and-restores--retry-without-granting-twice"></a>

## Retry without granting twice

A timeout can happen after a server commit. Retry the same store evidence and allow server idempotency to decide whether it was already accepted. Do not create a new artificial purchase ID to force success.

SDKs automatically retry only once after a 401 token refresh. They do not universally retry 429, validation errors, or store outages. Implement bounded application retry where appropriate, respect `Retry-After`, and leave durable store purchase completion pending until the backend acknowledges acceptance.

A 404 subscriber response before first purchase means no customer alias exists yet. A 200 receipt response does not prove a product was mapped to the access level your application expects; read the subscriber and inspect its mapping.

<a id="receipts-and-restores--renewals-and-cancellation"></a>

## Renewals and cancellation

Store notifications provide signed/authenticated triggers for subsequent changes. Google notification integers are a signal to read current store state, not the final purchase truth. Apple nested signed transaction/renewal data is independently verified.

Turning off auto-renewal can leave the subscription active through its paid expiry. Grace and billing retry are separate store states; inspect verified access and expiry rather than treating every cancellation as immediate loss of service. Free trials do not count as funded paid renewals.

<a id="receipts-and-restores--refunds-and-revocations"></a>

## Refunds and revocations

Verified refund/revocation observations update transaction/access projections. Full refunds and chargebacks can remove the affected purchase's grant. Google partial refunds preserve remaining quantity according to the authoritative observation and refund history. Other active grants can still keep an access level active.

CherrySub reports store refunds and refund requests; the current dashboard does not issue a refund in the store. Use the store's authorized refund process, then verify the resulting state and event in CherrySub.

<a id="receipts-and-restores--restore-and-transaction-listeners"></a>

## Restore and transaction listeners

Listen for unfinished/restored purchases using your storefront library, submit valid evidence under the intended authenticated App User ID, and complete only after success. Implement both the interactive purchase response and the storefront's update/recovery stream; an app can be interrupted between purchase and completion.

A verified restore can transfer a purchase binding without merging customer identities. Test this explicitly with two application accounts. See [customer identity](#customer-identity) and the [sandbox test matrix](#testing).


---

<a id="dashboard"></a>

# Dashboard navigation

The dashboard is a project- and app-scoped view of verified purchase data and configuration. Begin by checking the project/app switcher. A filter or detail URL does not authorize access to another project.

<a id="dashboard--every-screen-at-a-glance"></a>

## Every screen at a glance

| Screen | What you do there | Typical next step |
| --- | --- | --- |
| Overview | Inspect recent health, revenue by currency, activity, and setup signals | Open a chart or a recent record |
| Charts | Choose a metric, range, filters, comparison, visualization, and currency | Inspect the series, save it, or export |
| Saved views | Open/edit reusable customer, transaction, or event filters | Drill into a current result |
| Customers | Search app-user identities and open access/purchase history | Inspect a transaction or add a support note |
| Transactions | Filter current store transaction projections | Open the transaction and its customer |
| Event stream | Inspect normalized immutable observations and changes | Trace the related transaction/customer |
| Product sets | Create/edit the ordered packages presented by the SDK | Mark a set current and read it from the client |
| Products | Register store IDs and inspect store facts/mappings | Add the correct access mapping |
| Access levels | Define stable feature access and inspect mapped grants | Check a customer's resulting access |
| Apps | Create, inspect, select, and edit store app identities | Connect a store or obtain SDK keys |
| API keys | Create and revoke app secret/publishable keys | Update backend or client configuration |
| Integrations | Enter store setup or webhook configuration | Verify a purchase or inspect delivery |
| Account | Manage your password and signed-in sessions | Revoke an old session or sign out |
| Project settings | Rename the project, manage team invitations/roles, read activity | Invite a teammate or review a change |
| Setup guide | Follow the app/access/test-purchase checkpoints | Open the relevant configuration screen |

The public marketing pages explain use cases. The documentation center is the detailed operating reference; it does not require a dashboard login.

<a id="dashboard--find-the-right-scope"></a>

## Find the right scope

The switcher chooses both the project and reporting app. App detail pages can be inspected without changing the active app; their selection action explicitly changes the wider context. Some detail links carry an app identifier to avoid ambiguity when two apps have the same store product ID.

On tablets/phones, navigation rails collapse or scroll. Use the navigation control to reach sections; horizontal chart/filter strips can be scrolled where needed. A hidden management action under **View access** is a role restriction, not a loading failure.

<a id="dashboard--filter-and-drill-down"></a>

## Filter and drill down

Search, platform, environment, status/type, time ranges, and sort controls vary by dataset. Filters are reflected in URLs where supported. Opening a detail and returning should preserve the originating list's filter/tab state.

Pagination applies to the filtered dataset. If the chosen page no longer has results after a filter change or deletion, return to the first page or clear the restrictive filter. An empty result is different from a request failure; use the displayed retry/error state when data could not be loaded.

<a id="dashboard--overview-versus-detailed-reports"></a>

## Overview versus detailed reports

Overview provides a bounded recent history (up to 180 UTC days) and summaries for orientation. Charts provide metric-specific windows and deeper analytical controls. For complete normalized raw history, queue a durable export rather than assuming a chart response contains every source row.

A saved chart is a saved analytical configuration. A saved view is a saved live dataset query. They are separate tools; [saved views and exports](#exports-and-saved-views) explains the difference.

<a id="dashboard--configuration-workflows"></a>

## Configuration workflows

After creating an app or API key, copy one-time secrets before dismissing the result. A successful store credential upload means it was accepted for storage; check the next steps for notifications and a real sandbox purchase. Product mapping and product presentation are also separate checkpoints.

A settings change that fails validation should be corrected from the error shown. A session expiration returns you to sign-in with the local destination preserved. If a resource disappears because membership/app scope changed, reselect the intended project instead of reusing an old link as authority.

<a id="dashboard--use-the-dedicated-guides"></a>

## Use the dedicated guides

For investigation, read [Customers](#customers) and [Transactions and events](#transactions-and-events). For analysis, read [Charts](#analytics), [Metrics](#metrics), and [Cohorts](#cohorts). For configuration, follow [Quickstart](#quickstart) and the store/catalog guides.

<a id="dashboard--import-an-existing-catalog"></a>

## Import an existing catalog

Open **Migration** in the Configure navigation group, or **Products → Import from store**. Choose RevenueCat, CSV/JSON, App Store, or Google Play. Store imports load a complete product selection; file imports validate a portable catalog. The preview separates additions, existing entries, and conflicts. Managers apply the plan, download its report, and inspect recent import history.

A successful screen says **Catalog imported**. Customer migration is a distinct verified-purchase step; opening an import report is not evidence that all prior purchases or historical analytics have moved. The [migration guide](#migration) covers that follow-up, Google base-plan policy, source credentials, and CLI checkpoints.


---

<a id="customers"></a>

# Customers and support notes

A customer detail brings together App User IDs, current access, store subscription lifecycle, transactions, and immutable event history. It is an investigation surface, not an arbitrary grant/refund editor.

<a id="customers--locate-the-customer"></a>

## Locate the customer

Select the correct project/app, open **Customers**, and search by the app identity or other supported list filters. Open the result and confirm the App User IDs listed on the profile against the stable identity your application backend authenticates.

A customer token is not itself a purchase. If a user has never produced qualifying receipt evidence, they may not yet have a customer alias/subscriber record. A missing record is different from an existing customer with inactive access.

<a id="customers--read-the-profile"></a>

## Read the profile

The summary shows spend information, subscription lifecycle count, current access, and the first recorded customer date. Spend is store-derived and currency-aware; missing accounting data is not proof of a zero-value purchase. Subscription records are renewal lineages, not a count of every transaction.

The profile tabs separate overview/lifecycle, history, and transactions. Use them to answer different questions:

- **What can this user access now?** Inspect the resolved access map and expiry.
- **What did the store report?** Inspect lifecycle status/phase and the event history.
- **What was purchased or refunded?** Open the transaction detail and its store identifiers.
- **Why did ownership change?** Inspect the receipt/restore observations and App User IDs around the change.

<a id="customers--interpret-access-correctly"></a>

## Interpret access correctly

Access is a union of mapped product grants across stores. An inactive subscription does not necessarily remove a lifetime or other-store grant. Cancellation can leave a paid period active until expiry. Expiry is evaluated at read time rather than waiting for a scheduled mutation.

Check the product's access mapping, grant mode, expiry, revocation, and the current owner of the purchase. A refunded transaction may affect one grant while another still grants `pro`. [Access levels](#access-levels) · [Customer identity](#customer-identity).

<a id="customers--add-and-maintain-support-notes"></a>

## Add and maintain support notes

Owners and admins can add, edit, and delete notes from the customer profile. Notes are project-owned investigation context and do not alter store state, identity, purchases, access, or ledger events.

Use notes for a concise support case reference and the resolution. The current limit is 20 notes per customer and 1000 UTF-8 bytes per note. The form's character count can differ for non-ASCII text because backend limits are byte-based.

Do not paste API keys, customer tokens, store private keys, raw credentials, or unnecessary personal data into a note. Delete an obsolete note through its confirmation flow; this deletes the note, not the customer's history. Viewers can inspect the profile but cannot enter note write flows.

<a id="customers--a-repeatable-support-investigation"></a>

## A repeatable support investigation

1. Confirm app, platform, and customer identity.
2. Locate the purchase/renewal transaction and note its product and store transaction ID.
3. Compare the verified expiry/revocation to the expected paid period.
4. Inspect the product access mapping and other grants contributing to the same level.
5. Review events for duplicate, late, refund, or restore observations.
6. Compare your application's broker subject and cached UI state to the current subscriber response.
7. Record the finding in a support note without credential material.

CherrySub currently does not offer a general customer-merge UI, manual premium override, store refund-issuance action, or self-service customer deletion. Use the [migration](#migration) and [release-status](#limits-and-release-status) guidance rather than assuming those operations exist.


---

<a id="transactions-and-events"></a>

# Transactions and the event stream

Events are immutable evidence; transactions and subscription lineages are projections of verified store state. One transaction can be associated with several observations, while one Google purchase observation can contain multiple line items. Counts from these views are therefore not interchangeable.

<a id="transactions-and-events--use-transactions-for-purchase-state"></a>

## Use Transactions for purchase state

Open **Transactions**, choose the app/environment/platform and supported filters, and locate the purchase. Open its detail to inspect store identifiers, original transaction/lineage, product, status, purchase/expiry/revocation times, monetary facts where available, line items, related customer, and source evidence.

A transaction's state can be refined by later authoritative observations. Google order enrichment can add accounting facts without making a separate access decision. Missing prices or currency are not filled with a guessed value.

Refunded transaction values and timeline attribution depend on the chosen metric. In the standard revenue cohorts, known refunds are assigned to the original purchase period. The date on which a refund was observed can therefore differ from the chart bucket carrying its financial effect.

<a id="transactions-and-events--use-event-stream-for-evidence-and-change-history"></a>

## Use Event stream for evidence and change history

The stream contains normalized ledger events, including store/client observations and derived access changes. Filter the relevant categories, platform, environment, status, and time range, then trace the related transaction or customer.

The ledger is append-only. A later correction is represented by later evidence and a changed projection, not by silently rewriting the original event. Store verification occurs before trusted claims are used for app attribution and projection.

Event numbers are monotonic but can have gaps. They are not a guarantee of database commit order. Client-facing receipt/subscriber event IDs are decimal strings to preserve the full 64-bit range in JavaScript/Dart. Do not build an external durable polling cursor solely from the largest event ID and assume no lower-ID transaction can commit later.

<a id="transactions-and-events--understand-environment-attribution"></a>

## Understand environment attribution

Apple signs `production` or `sandbox`. Google RTDN is a doorbell without a definitive purchase environment, so the notification can be recorded as `unknown`; the subsequent authenticated purchase read establishes the actual purchase state. Unknown is not silently included as production.

A sandbox filter can show the transaction while an associated earlier RTDN appears under unknown. Inspect the complete lineage rather than concluding that one of the records belongs to another app.

<a id="transactions-and-events--duplicate-and-late-delivery"></a>

## Duplicate and late delivery

Stores and clients can resend evidence. Idempotency distinguishes duplicate delivery from a new authoritative observation, and stale-state reduction prevents an old observation from overwriting newer truth. A successful duplicate receipt response does not create another sale.

If you see an unexpected change, compare store timestamps, received times, purchase ownership, product mappings, and the resulting current projection. They answer different temporal questions.

<a id="transactions-and-events--investigation-examples"></a>

## Investigation examples

- **Purchase exists, access absent:** check customer ownership and the versioned product access mapping, then expiry/revocation.
- **Many events, one transaction:** several notifications/receipts can refer to the same purchase.
- **Refund seen today changes an older revenue bucket:** the selected chart may attribute known refunds to original purchase cohorts.
- **Google price arrives later:** order enrichment is separate from purchase-state verification.
- **No expected outbound webhook:** outbound delivery fans out supported derived events such as `access.changed`, not every raw store observation.

Follow [metrics](#metrics), [webhooks](#webhooks), and [troubleshooting](#troubleshooting) for the relevant contract. For a complete normalized event/transaction history, queue an [export](#exports-and-saved-views).


---

<a id="analytics"></a>

# Charts and reporting

Charts answer a specific metric question over an app, period, and filter set. Choose the metric first: time series, current snapshots, retention matrices, and customer cohorts do not have the same meaning or supported controls.

<a id="analytics--configure-a-report"></a>

## Configure a report

1. Confirm the project/app.
2. Choose the metric from Revenue, Subscriptions, Trials, Health, Customers, or Activity.
3. Select a relative or custom date range and resolution.
4. Apply the supported store, environment, and product filters.
5. For monetary reports, choose one of the currencies actually present.
6. Select comparison, visualization, or store segmentation where that metric supports it.
7. Inspect both values and the metric's explanatory text before saving or exporting.

Some specialized reports intentionally hide controls that do not apply. Retention is a cohort matrix, for example, rather than a general-purpose line/area/bar report.

<a id="analytics--dates-and-resolution"></a>

## Dates and resolution

Dates use UTC and custom start/end dates are inclusive. Relative ranges are 7, 28, 90, and 365 days. Custom daily ranges are bounded to 366 days; weekly ranges to five years; monthly ranges to ten years. Broader ranges require the coarser supported resolution.

A period-end snapshot is different from a count of things that happened inside a period. Current status/phase/access-by-store views describe current projections, not reconstructed daily historical status. The [metric dictionary](#metrics) distinguishes each series.

For a custom range touching a partial week/month, inspect the labels and bucket boundaries. The preview's movement calculations can include broader calendar buckets at partial-month boundaries; reconcile the source transactions when precise boundary accounting is required.

<a id="analytics--currency-and-money"></a>

## Currency and money

Monetary series stay separated by currency. A currency selector changes the series, not an exchange rate. CherrySub does not convert all store value to USD or infer a project reporting currency.

Backend values use milliunits: 1000 milliunits equal one unit of the stated currency. Gross means store-reported purchase value. Net in these reports means gross minus known refunds; it is not developer proceeds after taxes and store commission.

Missing accounting information is distinct from zero. A net-zero result with nonzero gross/refunds is still meaningful data. Use the selected report's data and error state rather than treating an unavailable report as an empty dataset.

<a id="analytics--comparison-and-visualization"></a>

## Comparison and visualization

Supported time series can compare with the preceding period and switch line, area, or bar presentation. Store segmentation separates Apple and Google within the chosen metric/currency. These controls do not redefine the metric's denominator or create unsupported dimensions.

Conversion, retention, lifetime-value, refund-review, and cohort reports expose their own windows/maturity controls. Read [Cohorts](#cohorts) before comparing them to ordinary transaction-date totals.

<a id="analytics--freshness-and-failure"></a>

## Freshness and failure

Identical authorized report requests share a bounded 30-second in-process cache. Each interactive report has an eight-second database calculation deadline. A slow/unavailable report is shown as a failure with retry, rather than being silently replaced with a different range or stale-looking substitute.

Overview has its own recent-history limit of 180 UTC days. For long raw history, use a durable export. A chart's aggregate points cannot reconstruct every contributing customer or event.

<a id="analytics--save-share-and-export"></a>

## Save, share, and export

Save the complete chart configuration for later use. A shared chart URL preserves its app/metric/filter setup but grants no access to a recipient. Members still authenticate and must belong to the project.

Download a chart CSV for the currently loaded aggregate response. Use **Email a data export** for normalized transaction/event rows; that is a separate asynchronous job with its own privacy and size limits. [Saved analyses and exports](#exports-and-saved-views).


---

<a id="metrics"></a>

# Metric definitions

This dictionary names every selectable chart metric in the current dashboard. Metrics are calculated from verified observations and projections; they are not interchangeable with store payout reports. Keep app, environment, product filter, currency, range, and resolution alongside any exported number.

<a id="metrics--units-and-attribution"></a>

## Units and attribution

Counts represent the named transaction, lineage, customer, or event population. Rates in the UI are percentages; their backing ratio series use basis points (10,000 means 100%). Monetary series use integer milliunits of a stated currency, where 1000 milliunits equal one currency unit.

Standard gross/net/refund charts attribute known refunds to the original purchase period. LTV windows instead include refunds according to the refund timestamp inside the selected customer lifetime. Refund-request outcomes use the request period. Choose the right temporal question before comparing them.

<a id="metrics--revenue-and-run-rate"></a>

## Revenue and run rate

<a id="metrics--net-revenue"></a>

### Net revenue

Metric ID: `net`. Sales minus refunds, attributed to the purchase date. Each currency is shown separately.

<a id="metrics--gross-revenue"></a>

### Gross revenue

Metric ID: `gross`. Store-reported purchase value before refunds; currencies remain separate.

<a id="metrics--cohort-refunded-value"></a>

### Cohort refunded value

Metric ID: `refunds`. Known full and partial refund amounts assigned to the original purchase period for cohort analysis.

<a id="metrics--refunded-transaction-cohorts"></a>

### Refunded transaction cohorts

Metric ID: `refundTransactions`. Paid and unpaid transactions with a known full or partial refund, grouped by original purchase date.

<a id="metrics--non-subscription-purchases"></a>

### Non-subscription purchases

Metric ID: `nonSubscriptionPurchases`. Consumable, non-consumable and non-renewing purchases counted in the period they were bought; Google prepaid subscription plans and family-shared entitlements are excluded.

<a id="metrics--realized-ltv-per-customer"></a>

### Realized LTV per customer

Metric ID: `realizedLtvPerCustomer`. Gross store value minus refunds generated inside each first-seen cohort's selected customer lifetime, divided by every new customer.

<a id="metrics--realized-ltv-per-paying-customer"></a>

### Realized LTV per paying customer

Metric ID: `realizedLtvPerPayingCustomer`. Gross store value minus refunds generated inside each first-seen cohort's selected customer lifetime, divided by customers with net-positive funded value.

<a id="metrics--monthly-run-rate"></a>

### Monthly run rate

Metric ID: `mrr`. Active paid subscription value normalized to one month at each period end; currencies remain separate.

<a id="metrics--annual-run-rate"></a>

### Annual run rate

Metric ID: `arr`. Annualized paid subscription run rate: each period-end monthly run rate multiplied by twelve.

<a id="metrics--run-rate-movement"></a>

### Run-rate movement

Metric ID: `mrrMovement`. New, returning, expanded, contracted, and churned monthly run rate in each period.


<a id="metrics--subscription-activity"></a>

## Subscription activity

<a id="metrics--paid-subscriptions"></a>

### Paid subscriptions

Metric ID: `activePaidSubscriptions`. Unique paid subscriptions that remain unexpired at each period end; cancelled access stays active until expiry.

<a id="metrics--paid-movement"></a>

### Paid movement

Metric ID: `paidSubscriptionMovement`. New, returning, and ended paid subscriptions explain the net change in each period.

<a id="metrics--subscription-retention"></a>

### Subscription retention

Metric ID: `subscriptionRetention`. Paid subscription cohorts and the share that made each duration-matched renewal; incomplete cohorts are never projected.

<a id="metrics--cohort-matrix"></a>

### Cohort matrix

Metric ID: `cohortExplorer`. A cohort of customers followed forward month by month, anchored by first seen, initial conversion or first unrefunded purchase; month 0 is the cohort's own month and currencies are never combined.

<a id="metrics--new-subscription-chains"></a>

### New subscription chains

Metric ID: `newSubscriptions`. First projected transaction in each store subscription renewal chain.

<a id="metrics--repeat-subscription-transactions"></a>

### Repeat subscription transactions

Metric ID: `renewals`. Subsequent projected transactions after the first transaction in each renewal chain.

<a id="metrics--current-status"></a>

### Current status

Metric ID: `subscriptionStatus`. Current subscription lineages grouped by authoritative projected status.

<a id="metrics--current-phases"></a>

### Current phases

Metric ID: `subscriptionPhases`. Current subscription lineages grouped by trial, introductory offer, or normal phase.

<a id="metrics--current-access-by-store"></a>

### Current access by store

Metric ID: `activeByStore`. Current subscription access, including paid and trial phases, split by store.


<a id="metrics--trials-and-health"></a>

## Trials and health

<a id="metrics--active-trials"></a>

### Active trials

Metric ID: `activeTrials`. Unexpired free trials at each period end, including trials whose auto-renewal was disabled.

<a id="metrics--trial-movement"></a>

### Trial movement

Metric ID: `trialMovement`. Free trials that started and ended in each period, with their net change.

<a id="metrics--trial-conversion-rate"></a>

### Trial conversion rate

Metric ID: `trialConversionRate`. Trial-start cohorts that convert directly into a funded subscription; open cohorts remain incomplete and later resubscribes are excluded.

<a id="metrics--trial-cancellation-rate"></a>

### Trial cancellation rate

Metric ID: `trialCancellationRate`. Trial-start cohorts that ended without converting after the customer turned auto-renewal off inside the selected window; trials that convert after an opt-out are not cancellations.

<a id="metrics--paid-churn"></a>

### Paid churn

Metric ID: `paidChurnRate`. Paid subscriptions that ended in a period as a share of paid subscriptions active at its start.

<a id="metrics--refund-rate"></a>

### Refund rate

Metric ID: `refundRate`. Paid transaction cohorts that were fully or partially refunded, attributed to their purchase period.

<a id="metrics--refund-request-outcomes"></a>

### Refund request outcomes

Metric ID: `refundRequests`. Store refund reviews matched to a tracked transaction, counted in the period the request arrived and split by the decision that followed; reversals are App Store only, and a request the store never answered becomes a decline after 48 hours.


<a id="metrics--customers-and-events"></a>

## Customers and events

<a id="metrics--initial-conversion"></a>

### Initial conversion

Metric ID: `initialConversion`. New-customer cohorts that start any subscription or purchase any product within the selected conversion timeframe, including free trials.

<a id="metrics--conversion-to-paying"></a>

### Conversion to paying

Metric ID: `conversionToPaying`. New-customer cohorts that make a funded purchase within the selected conversion timeframe; free trials alone are excluded.

<a id="metrics--customers-with-store-activity"></a>

### Customers with store activity

Metric ID: `activeCustomers`. Distinct App User IDs that produced at least one matching store event in each period.

<a id="metrics--new-customers"></a>

### New customers

Metric ID: `newCustomers`. Customer identities first created in the selected window and matching the current filters.

<a id="metrics--transactions"></a>

### Transactions

Metric ID: `transactions`. Projected store transactions observed in the selected window.

<a id="metrics--events"></a>

### Events

Metric ID: `events`. Normalized immutable ledger events received each day.

<a id="metrics--formulas-and-denominators"></a>

## Formulas and denominators

| Metric | Calculation / interpretation |
| --- | --- |
| Net revenue | Gross store-reported value − known full/partial refunds in that purchase cohort |
| Annual run rate | Period-end monthly run rate × 12 |
| Run-rate movement | New + returning + expansion − contraction − churn |
| Paid movement | New + returning − ended paid lineages |
| Trial movement | Started trials − ended trials |
| Paid churn | Ended paid lineages / paid lineages active at the start of the period |
| Refund rate | Paid transactions with a known full/partial refund / paid transactions in the purchase cohort |
| Initial conversion | New customers with a qualifying trial/purchase in the window / new-customer cohort |
| Conversion to paying | New customers with a qualifying funded purchase in the window / new-customer cohort |
| Realized LTV per customer | In-lifetime net observed value / every customer in the first-seen cohort |
| Realized LTV per paying customer | In-lifetime net observed value / net-positive funded customers, separately per currency |
| Relative subscription retention | Qualifying retained journeys at that renewal opportunity / original paid cohort |

A zero or unavailable denominator does not justify manufacturing a 0% success rate. Use the report's undefined/incomplete state. Cohort maturity and opt-out/reversal rules are detailed in [Cohorts](#cohorts).

<a id="metrics--monthly-normalization"></a>

## Monthly normalization

The current implementation normalizes each observed funded service period and then evaluates active value at period end. It uses these observed-duration bands, rounded to whole days:

| Observed days | Multiplier applied to paid value |
| --- | --- |
| 0–1 | 30 |
| 3 | 10 |
| 6–8 | 4 |
| 12–16 | 2 |
| 27–33 | 1 |
| 58–62 | 1/2 |
| 88–95 | 1/3 |
| 179–185 | 1/6 |
| 363–375 | 1/12 |
| Other valid durations | 28 / observed duration in days |

Normalization uses effective observed service duration, so a grace/expiry extension can affect the estimate. This is a run-rate estimate, not a contractual invoice schedule or a tax/accounting close. The preview also has partial-month boundary limitations in movement buckets; reconcile source transactions for a precise partial-period close.

<a id="metrics--read-refunds-and-request-outcomes-carefully"></a>

## Read refunds and request outcomes carefully

Refund rate includes paid transactions with any known full/partial refund and excludes family-shared purchases from its paid denominator. Refunded transaction cohorts also include unpaid refunded transactions, so their counts need not equal the refund-rate numerator.

Refund requests are review events, not refund amounts or a button to issue refunds. They are matched to tracked transactions and grouped by request date. Apple can report reversals. A store request without a later decision is classified as declined after 48 hours; an explicit later decision wins when it arrives. That timeout is an analytical rule, not a statement that Google sent a decline notification.

<a id="metrics--what-these-numbers-do-not-include"></a>

## What these numbers do not include

Currencies are not converted or summed into an invented reporting currency. Taxes, store commission, developer proceeds, future predicted LTV, and unidentified/unknown product types are not inferred. Data can be incomplete when an app has not ingested history or accounting enrichment is missing.

A current status/phase snapshot does not reconstruct every past daily state. Customers with store activity are not product engagement DAU/MAU. Repeated transaction/notification observations are not automatically repeated purchases. Use [Transactions and events](#transactions-and-events) to resolve the evidence behind an unexpected number.


---

<a id="cohorts"></a>

# Retention, conversion, and cohorts

A cohort groups customers or subscription journeys by an anchor date and follows later outcomes. Always identify the anchor, denominator, observation window, and maturity before comparing two cohort charts.

<a id="cohorts--subscription-retention"></a>

## Subscription retention

The anchor is the subscription's first funded service period, not the beginning of a free trial. Cohorts are grouped by paid-start month and filtered to weekly, monthly, or yearly product duration. Catalog package type supplies duration when available; bounded observed service length is the fallback for older data.

Each renewal is measured against the original paid cohort. A product/duration change starts a new journey. A same-product resubscribe after a gap stays in its original journey. Grace time alone is not a funded renewal, and refunded/revoked periods do not count as retained.

Relative cells remain empty until every subscription in the cohort has reached that renewal opportunity. Do not read an immature blank as 0% retention. Absolute mode shows the corresponding counts; relative mode divides by the original cohort.

<a id="cohorts--trial-conversion-and-cancellation"></a>

## Trial conversion and cancellation

Trial conversion cohorts start at the trial's own beginning. Overlapping observations of the same trial collapse into one trial. A funded, non-refunded period must start at the trial boundary to count as direct conversion; a later resubscribe is not attributed back to that trial.

Trial starts reconcile into converted, open, and abandoned. Open trials make a cohort incomplete. Trial cancellation is narrower than abandonment: the trial must end without conversion after an auto-renewal opt-out inside the chosen window. An opt-out that was reversed before the user paid is not a cancellation.

Cancellation windows are 1, 2, 5, or 7 days, or unbounded. They are measured as N × 24 hours from the trial timestamp, not N UTC date labels. If the user opted out repeatedly, the last in-trial decision matters.

<a id="cohorts--customer-conversion"></a>

## Customer conversion

Initial conversion and conversion to paying use the customer's first-seen date as the anchor. First seen means the CherrySub customer record, not every app install or account registration in your own system. Minting a customer token alone does not add a customer to this denominator. Windows are Day 0, 3, 7, 14, 30, or unbounded.

Initial conversion includes trial starts and product purchases. Conversion to paying requires a funded transaction; a free trial alone is not paying. Store/environment/product filters affect the conversion numerator, not the new-customer denominator. A recent cohort can still be maturing.

Refund timing matters: a refund removes paid conversion only from windows in which the refund occurred. Do not compare a seven-day conversion window to an unlimited lifetime outcome as if their samples were identical.

<a id="cohorts--realized-lifetime-value"></a>

## Realized lifetime value

Both LTV reports use first-seen customer cohorts and preserve each currency separately. A lifetime window is Day-0 inclusive: Day 7 includes eight UTC dates, Day 0 through Day 7. Available windows are 0, 3, 7, 14, 30, 60, 90, 180, 365 days, or unbounded.

Revenue includes purchase value in that lifetime and subtracts refunds whose timestamps fall within the same lifetime. Per-customer LTV divides by every new customer. Per-paying-customer LTV divides, per currency, by customers with net-positive funded value in that lifetime. Filters do not silently shrink the all-customer denominator.

This is realized observed value, not a forecast of future lifetime revenue. Taxes, commission, and currency conversion are not inferred.

<a id="cohorts--cohort-matrix"></a>

## Cohort matrix

Choose one anchor: new customers, initial conversions, or new paying customers. A conversion anchor is the first qualifying event over the customer's history, then filtered into the requested cohort range. A first purchase that was refunded does not by itself establish an unrefunded paying anchor.

Month 0 is the cohort's own calendar month. This differs from subscription retention's renewal-opportunity columns. Measures are revenue, cumulative realized LTV, realized LTV per customer, or retained subscriptions. Choose an actually earned currency for monetary measures.

Revenue/refunds are assigned to the corresponding purchase period in this matrix; cumulative LTV builds from those periods. Cohort size remains the denominator over time. Retained subscriptions count unique chains at period end. Future/unlived periods remain absent from totals.

<a id="cohorts--interpret-the-result"></a>

## Interpret the result

Before reporting a change, record the anchor, selected product/store/environment, currency, lifetime/window, and maturity. A shifted denominator, incomplete cohort, or different refund attribution can explain a difference without a behavioral change.

For a customer-level example, trace the first-seen, trial, purchase, and refund dates through [Transactions and events](#transactions-and-events). For every available metric name and formula, use [Metric definitions](#metrics).


---

<a id="exports-and-saved-views"></a>

# Saved views, saved charts, and exports

A saved view keeps a live dataset query. A saved chart keeps an analytical configuration. A CSV export captures data at a particular generation point. They are separate features with different scope and completeness.

<a id="exports-and-saved-views--saved-views"></a>

## Saved views

Open **Saved views → New saved view**, name it, choose Customers, Transactions, or Events, and define the supported filters. Reopening the view runs the query against current authorized data, so its rows can change over time.

Edit the name/query when your investigation changes, or delete the saved view when it is no longer useful. Deleting the view does not delete its underlying transactions, customers, or events. Detail drill-down and return navigation retain the originating view/filter context where supported.

Saved views belong to the requesting account within its project access. A link is not an access token or a way to share a private report with an unrelated user.

<a id="exports-and-saved-views--saved-charts"></a>

## Saved charts

From Charts, save the current app, metric, range, resolution, comparison, store/environment/product filters, visualization, segmentation, currency, and applicable cohort/window settings. Reopen it to rerun that configuration.

Switching to a different metric must use supported settings for that metric. A retention matrix does not preserve an irrelevant area-chart selection as a hidden extra behavior. A shared chart URL can reproduce configuration for an authorized recipient but does not grant membership.

<a id="exports-and-saved-views--aggregate-chart-csv"></a>

## Aggregate chart CSV

A chart CSV contains the aggregate report already loaded on screen, with its selected scope and range. It is useful for chart analysis and does not contain every contributing customer or raw store payload.

If the requested report failed, its export is unavailable. Do not silently replace an unavailable custom range with full history. To investigate every contributing normalized event/transaction row, use the durable export flow instead.

<a id="exports-and-saved-views--email-a-normalized-data-export"></a>

## Email a normalized data export

Open **Email a data export** from the supported reporting surface. Choose Transaction history or Event history, the selected range or supported full-history option, and the applicable app/store/environment/product filters. Submit **Queue export** and inspect its status in the dialog.

The job is requester-private. Another project member cannot use your session to list or download your export simply because they share the project. The CSV is generated asynchronously and delivered as a link to your account email, not as an email attachment.

| Limit | Current behavior |
| --- | --- |
| Active jobs | One active export per verified account |
| Request rate | Ten requests per project/account in a rolling 24 hours |
| Explicit date span | At most ten years |
| Rows | Up to 500,000 |
| File size | Up to 32 MiB |
| Generation query deadline | Ten minutes |
| Lease recovery | At most five attempts |
| Download-link lifetime | 24 hours |

An oversized job fails with a size error rather than silently giving you a truncated complete report. Narrow the time range or filters and try again. A busy account must wait for its active job to finish.

<a id="exports-and-saved-views--download-and-privacy"></a>

## Download and privacy

The gateway stores the report in private object storage and sends a bounded-lifetime link. Its secret token stays in the URL fragment and is submitted via same-origin POST, rather than being placed in request URLs/referrers. Expired links require a new export.

Export fields are normalized reporting data. Raw ledger payloads, API credentials, signing material, and account tokens are not included. Spreadsheet formula prefixes in store-controlled identifiers are neutralized, which can explain an escaped identifier when opening the CSV in a spreadsheet.

<a id="exports-and-saved-views--troubleshoot-delivery"></a>

## Troubleshoot delivery

Check job state, account email verification, the latest email, link expiry, and the self-hosted email/report gateway configuration. A queued job is not proof of a delivered email. For a large history, split it into bounded ranges. An aggregate chart and a transaction export can legitimately differ because they use different metric attribution rules. [Metrics](#metrics) · [Operations](#self-hosting).


---

<a id="webhooks"></a>

# Outbound webhooks

Outbound webhooks notify your backend about supported derived changes. They are distinct from Apple's incoming server notifications and Google's incoming RTDN. In the current implementation, the derived customer-access event is `access.changed`; a wildcard does not turn every raw store notification into a delivery.

<a id="webhooks--create-an-endpoint"></a>

## Create an endpoint

In **Integrations → Webhooks**, add a public HTTPS receiver URL, a description/name, and event filters. Use `access.changed` for customer-access changes or the supported wildcard when you intend to receive all derived event types.

Copy the endpoint's one-time signing secret and store it in your receiver's secret configuration. The endpoint list does not reveal it again. The equivalent trusted API is `POST /v1/webhook-endpoints` using the app's secret key; public publishable keys cannot manage endpoints.

Private, loopback, and other special-network targets are rejected, DNS is checked again at connection time, and redirects are not followed. A tunnel/public staging receiver must terminate at a stable HTTPS URL that the service can reach.

<a id="webhooks--verify-before-parsing-or-processing"></a>

## Verify before parsing or processing

CherrySub uses [Standard Webhooks](https://www.standardwebhooks.com/). Use a maintained verification library with the endpoint secret, the **unaltered raw request bytes**, and these headers:

| Header | Purpose |
| --- | --- |
| `webhook-id` | Stable delivery/message identifier across retries and manual replay |
| `webhook-timestamp` | Timestamp for this signed attempt |
| `webhook-signature` | HMAC signature(s), including overlap signatures during rotation |

Do not stringify a parsed JSON object and use it as the signed body: byte changes break verification. Preserve the raw body before framework middleware consumes it. Validate the timestamp tolerance as well as the signature to protect against replay. The [specification](https://github.com/standard-webhooks/standard-webhooks/blob/main/spec/standard-webhooks.md) defines the interoperable header/signature format.

The envelope has this shape:

```json
{
  "id": "evt_123",
  "type": "access.changed",
  "api_version": "2026-08-28",
  "timestamp": "2026-09-09T10:00:00Z",
  "event_number": 123,
  "data": {
    "customerId": "22222222-2222-4222-8222-222222222222",
    "appUserIds": ["user-123"],
    "accessLevelId": "33333333-3333-4333-8333-333333333333",
    "lookupKey": "pro",
    "current": {
      "active": true,
      "expiresAt": "2026-10-09T10:00:00Z",
      "platform": "apple",
      "storeIdentity": "store-lineage-example",
      "productId": "pro.monthly",
      "observedAt": "2026-09-09T10:00:00Z",
      "eventId": 122
    }
  }
}
```

This is an illustrative access-change payload. `previous` and `current` are optional snapshots depending on the transition. The webhook's numeric `event_number` and nested `eventId` are int64 values; unlike public receipt/subscriber `event_id` strings, JavaScript receivers must handle them without unsafe numeric rounding. Use the stable string IDs for idempotency rather than converting a rounded number back into an ID.

<a id="webhooks--make-receiver-processing-idempotent"></a>

## Make receiver processing idempotent

1. Verify the raw body and headers.
2. Atomically record the accepted `webhook-id` (or enqueue it with a uniqueness constraint) with your processing decision.
3. Return 2xx after durable acceptance. Repeated delivery of the same ID should also receive 2xx without repeating side effects.
4. Process expensive work asynchronously and make downstream side effects idempotent too.

A receipt may already have updated access before your receiver processes the webhook. For a current authorization decision, read the subscriber API. Expiry is evaluated at read time; there is no guarantee of a webhook arriving at the exact expiry instant without a new source observation.

<a id="webhooks--go-receiver-example"></a>

## Go receiver example

This handler verifies the timestamp and signature before handing raw bytes to an application-owned durable accept function. The accept function must atomically deduplicate by message ID and enqueue/process the event; returning nil for an already accepted ID is correct.

```go
package receiver

import (
    "context"
    "errors"
    "io"
    "net/http"

    standardwebhooks "github.com/standard-webhooks/standard-webhooks/libraries/go"
)

func Handler(
    secret string,
    accept func(context.Context, string, []byte) error,
) (http.Handler, error) {
    if accept == nil {
        return nil, errors.New("durable accept function is required")
    }
    verifier, err := standardwebhooks.NewWebhook(secret)
    if err != nil {
        return nil, err
    }
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        if r.Method != http.MethodPost {
            w.Header().Set("Allow", http.MethodPost)
            w.WriteHeader(http.StatusMethodNotAllowed)
            return
        }
        raw, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 20 << 10))
        if err != nil {
            w.WriteHeader(http.StatusBadRequest)
            return
        }
        if err := verifier.Verify(raw, r.Header); err != nil {
            w.WriteHeader(http.StatusUnauthorized)
            return
        }
        if err := accept(r.Context(), r.Header.Get("webhook-id"), raw); err != nil {
            w.WriteHeader(http.StatusServiceUnavailable)
            return
        }
        w.WriteHeader(http.StatusNoContent)
    }), nil
}
```

Use the secret returned by endpoint creation/rotation, a bounded receiver timeout, and an authenticated deployment configuration. Do not replace `Verify` with a timestamp-ignoring test helper in a live receiver. Keep int64 JSON values lossless when the accept function decodes the envelope.

<a id="webhooks--ordering-and-retries"></a>

## Ordering and retries

Deliveries are head-of-line ordered per endpoint. A failing endpoint delays its later deliveries, while other endpoints can continue. Event numbers can have gaps; do not wait forever for the missing integer.

Only 2xx succeeds. Retry delays begin at 1 minute, 5 minutes, 30 minutes, 2 hours, and 6 hours, then use 24-hour intervals within the final 72-hour retry window. `Retry-After` can extend a delay. HTTP 410 or exhaustion dead-letters the delivery and disables the endpoint.

The body and `webhook-id` remain stable; timestamps and signatures are regenerated for each attempt. Endpoint status/delivery status values include pending, in flight, retry, succeeded, dead letter, and cancelled. A disabled endpoint is not proof its queued history was processed successfully.

<a id="webhooks--inspect-and-replay"></a>

## Inspect and replay

Open the Deliveries tab, filter by status, and inspect an attempt's HTTP status, timing, safe error, and bounded response. Fix the receiver or credentials first. Use the explicit retry/replay action when appropriate, and enable the endpoint if its failure disabled it.

Replay keeps the same message ID, so your receiver's deduplication must continue to work. Deleting/disabling an endpoint does not delete the immutable source evidence. The [API reference](#api-reference) lists endpoint lifecycle and delivery-history operations.

<a id="webhooks--rotate-the-secret"></a>

## Rotate the secret

Rotate from the endpoint's action, copy the new one-time secret, update the receiver, and verify delivery. Rotation dual-signs with old/new keys for 24 hours. Coordinate the overlap instead of rejecting the old secret immediately during rollout.

Self-hosted deployments must retain encryption-key coverage for all still-live signing material. Missing key coverage can block startup; losing the encryption key is not repaired by reading a safe secret hint. [Security](#security) · [Operations](#self-hosting).


---

<a id="api-reference"></a>

# Runtime and admin API reference

This reference is generated from the reviewed OpenAPI contract. It covers all 37 runtime, store-ingress, webhook, health, and root-admin operations and their models. Download the [OpenAPI YAML](https://app-staging.cherrysub.com/docs/openapi.yaml) for the exact machine-readable contract, including nested/conditional schema constraints.

<a id="api-reference--choose-the-correct-api-boundary"></a>

## Choose the correct API boundary

The staging API origin is `https://api-staging.cherrysub.com`. Operator/local deployments use their own origin. Send JSON with the documented content type. The hosted dashboard's cookie-based `/auth` and `/dashboard` interface is a separate [hosted API](#hosted-api).

- Public client runtime: `iap_pk_` plus `X-IAP-Customer-Token` for the exact app/customer.
- Trusted app server: `iap_sk_`, including token minting and app-scoped catalog/webhook management.
- Root administration: an operator bearer key from `ADMIN_API_KEYS`; it is not a project-member credential.
- Store ingress: verified Apple signed data or authenticated Google Pub/Sub push, not an app bearer key pasted into a notification URL.

Root app creation returns the core app and its first secret key. The hosted project/app creation flow has a different response and project association; use the dashboard/CLI when creating an app in a hosted project.

Runtime error responses use an `error` field. Keep client-facing `event_id` values as decimal strings. Subscriber 404 can mean no app-user alias exists yet; current product-set `null` is an explicit successful empty configuration.

<a id="api-reference--operation-index"></a>

## Operation index

| Method | Path | Operation |
| --- | --- | --- |
| GET | [`/livez`](#api-reference--get-livez) | getLiveness |
| GET | [`/readyz`](#api-reference--get-readyz) | getReadiness |
| GET | [`/healthz`](#api-reference--get-healthz) | getHealth |
| GET | [`/metrics`](#api-reference--get-metrics) | getMetrics |
| POST | [`/ingest/apple/assn`](#api-reference--post-ingestappleassn) | ingestAppleNotification |
| POST | [`/ingest/google/rtdn`](#api-reference--post-ingestgooglertdn) | ingestGoogleNotification |
| POST | [`/v1/receipts`](#api-reference--post-v1receipts) | submitReceipt |
| GET | [`/v1/subscribers/{app_user_id}`](#api-reference--get-v1subscribersappuserid) | getSubscriber |
| GET | [`/v1/product-sets/current`](#api-reference--get-v1product-setscurrent) | getCurrentProductSet |
| GET | [`/v1/webhook-endpoints`](#api-reference--get-v1webhook-endpoints) | listWebhookEndpoints |
| POST | [`/v1/webhook-endpoints`](#api-reference--post-v1webhook-endpoints) | createWebhookEndpoint |
| GET | [`/v1/webhook-endpoints/{endpoint_id}`](#api-reference--get-v1webhook-endpointsendpointid) | getWebhookEndpoint |
| PATCH | [`/v1/webhook-endpoints/{endpoint_id}`](#api-reference--patch-v1webhook-endpointsendpointid) | updateWebhookEndpoint |
| DELETE | [`/v1/webhook-endpoints/{endpoint_id}`](#api-reference--delete-v1webhook-endpointsendpointid) | deleteWebhookEndpoint |
| POST | [`/v1/webhook-endpoints/{endpoint_id}/rotate-secret`](#api-reference--post-v1webhook-endpointsendpointidrotate-secret) | rotateWebhookEndpointSecret |
| GET | [`/v1/webhook-deliveries`](#api-reference--get-v1webhook-deliveries) | listWebhookDeliveries |
| GET | [`/v1/webhook-deliveries/{message_id}`](#api-reference--get-v1webhook-deliveriesmessageid) | getWebhookDelivery |
| POST | [`/v1/webhook-deliveries/{message_id}/retry`](#api-reference--post-v1webhook-deliveriesmessageidretry) | retryWebhookDelivery |
| GET | [`/admin/v1/apps`](#api-reference--get-adminv1apps) | listApps |
| POST | [`/admin/v1/apps`](#api-reference--post-adminv1apps) | createApp |
| GET | [`/admin/v1/apps/{app_id}`](#api-reference--get-adminv1appsappid) | getApp |
| PATCH | [`/admin/v1/apps/{app_id}`](#api-reference--patch-adminv1appsappid) | updateApp |
| DELETE | [`/admin/v1/apps/{app_id}`](#api-reference--delete-adminv1appsappid) | disableApp |
| GET | [`/admin/v1/apps/{app_id}/api-keys`](#api-reference--get-adminv1appsappidapi-keys) | listAppAPIKeys |
| POST | [`/admin/v1/apps/{app_id}/api-keys`](#api-reference--post-adminv1appsappidapi-keys) | createAppAPIKey |
| DELETE | [`/admin/v1/apps/{app_id}/api-keys/{key_id}`](#api-reference--delete-adminv1appsappidapi-keyskeyid) | revokeAppAPIKey |
| GET | [`/v1/access-levels`](#api-reference--get-v1access-levels) | listAccessLevels |
| POST | [`/v1/access-levels`](#api-reference--post-v1access-levels) | createAccessLevel |
| GET | [`/v1/access-levels/{access_level_id}`](#api-reference--get-v1access-levelsaccesslevelid) | getAccessLevel |
| PATCH | [`/v1/access-levels/{access_level_id}`](#api-reference--patch-v1access-levelsaccesslevelid) | updateAccessLevel |
| DELETE | [`/v1/access-levels/{access_level_id}`](#api-reference--delete-v1access-levelsaccesslevelid) | archiveAccessLevel |
| GET | [`/v1/product-access-mappings`](#api-reference--get-v1product-access-mappings) | listProductAccessMappings |
| POST | [`/v1/product-access-mappings`](#api-reference--post-v1product-access-mappings) | createProductAccessMapping |
| PATCH | [`/v1/product-access-mappings/{mapping_id}`](#api-reference--patch-v1product-access-mappingsmappingid) | updateProductAccessMapping |
| DELETE | [`/v1/product-access-mappings/{mapping_id}`](#api-reference--delete-v1product-access-mappingsmappingid) | deleteProductAccessMapping |
| POST | [`/v1/client-tokens`](#api-reference--post-v1client-tokens) | createClientToken |
| DELETE | [`/v1/client-tokens/{token_id}`](#api-reference--delete-v1client-tokenstokenid) | revokeClientToken |

<a id="api-reference--public-runtime-and-trusted-catalog"></a>

## Public runtime and trusted catalog

<a id="api-reference--post-v1receipts"></a>

### POST /v1/receipts

Operation: `submitReceipt`. A secret server key may submit directly. A publishable client key must also present X-IAP-Customer-Token and app_user_id must match its authenticated subject. The service then verifies an Apple StoreKit transaction JWS or authoritatively fetches a Google purchase token and atomically reduces mapped access levels.

Authentication: `bearerAPIKey`

| Parameter | Location | Required | Type | Details |
| --- | --- | --- | --- | --- |
| `X-IAP-Customer-Token` | header | No | string | Required with an iap_pk_ publishable key; ignored for an iap_sk_ server key. pattern: "^iap_ct_". |

Request body: required.

Content type: `application/json`. [ReceiptSubmission](#api-reference--receiptsubmission)


| Response | Meaning |
| --- | --- |
| 200 | Receipt observation appended or deduplicated. |
| 400 | Payload is malformed or fails store validation. |
| 401 | The required bearer credential is missing, invalid, or revoked. |
| 403 | The authenticated key or customer token is not authorized for this resource. |
| 413 | Request exceeds 256 KiB. |
| 422 | Payload is malformed or fails store validation. |
| 429 | The authenticated API key exceeded its process-local receipt rate limit. |
| 503 | A verifier or durable dependency is temporarily unavailable. |

**200 application/json body:** [IngestResult](#api-reference--ingestresult)
<a id="api-reference--get-v1subscribersappuserid"></a>

### GET /v1/subscribers/{app_user_id}

Operation: `getSubscriber`. Returns the current Apple and Google access level union for one app-scoped customer alias.

Authentication: `bearerAPIKey`

| Parameter | Location | Required | Type | Details |
| --- | --- | --- | --- | --- |
| `app_user_id` | path | Yes | string | minLength: 1. maxLength: 512. |
| `X-IAP-Customer-Token` | header | No | string | Required with an iap_pk_ publishable key; ignored for an iap_sk_ server key. pattern: "^iap_ct_". |

| Response | Meaning |
| --- | --- |
| 200 | Current subscriber access level projection. |
| 400 | Payload is malformed or fails store validation. |
| 401 | The required bearer credential is missing, invalid, or revoked. |
| 403 | The authenticated key or customer token is not authorized for this resource. |
| 404 | No customer alias exists for this app-user id. |
| 503 | A verifier or durable dependency is temporarily unavailable. |

**200 application/json body:** [Subscriber](#api-reference--subscriber)
<a id="api-reference--get-v1product-setscurrent"></a>

### GET /v1/product-sets/current

Operation: `getCurrentProductSet`. Returns the authenticated app's current cross-store product presentation. Publishable keys also require a customer token; secret keys do not.

Authentication: `bearerAPIKey`

| Parameter | Location | Required | Type | Details |
| --- | --- | --- | --- | --- |
| `X-IAP-Customer-Token` | header | No | string | Required with an iap_pk_ publishable key; ignored for an iap_sk_ server key. pattern: "^iap_ct_". |

| Response | Meaning |
| --- | --- |
| 200 | Current product set, or an explicit null when the app has not selected one. |
| 401 | The required bearer credential is missing, invalid, or revoked. |
| 403 | The authenticated key or customer token is not authorized for this resource. |
| 503 | A verifier or durable dependency is temporarily unavailable. |

**200 application/json body:** [CurrentProductSet](#api-reference--currentproductset)

Response headers: `Cache-Control` (const: "no-store".).
<a id="api-reference--get-v1access-levels"></a>

### GET /v1/access-levels

Operation: `listAccessLevels`. 

Authentication: `bearerAPIKey`


| Response | Meaning |
| --- | --- |
| 200 | Active app-scoped access level catalog. |
| 401 | The required bearer credential is missing, invalid, or revoked. |
| 503 | A verifier or durable dependency is temporarily unavailable. |

**200 application/json body:** 
```json
{
  "type": "object",
  "required": [
    "access_levels"
  ],
  "properties": {
    "access_levels": {
      "type": "array",
      "items": {
        "$ref": "#/components/schemas/CatalogAccessLevel"
      }
    }
  },
  "additionalProperties": false
}
```

<a id="api-reference--post-v1access-levels"></a>

### POST /v1/access-levels

Operation: `createAccessLevel`. 

Authentication: `bearerAPIKey`


Request body: required.

Content type: `application/json`. [CreateAccessLevel](#api-reference--createaccesslevel)


| Response | Meaning |
| --- | --- |
| 201 | Access level created. |
| 400 | Payload is malformed or fails store validation. |
| 401 | The required bearer credential is missing, invalid, or revoked. |
| 409 | The requested identifier or lifecycle transition conflicts with existing state. |
| 503 | A verifier or durable dependency is temporarily unavailable. |

**201 application/json body:** [CatalogAccessLevel](#api-reference--catalogaccesslevel)
<a id="api-reference--get-v1access-levelsaccesslevelid"></a>

### GET /v1/access-levels/{access_level_id}

Operation: `getAccessLevel`. 

Authentication: `bearerAPIKey`

| Parameter | Location | Required | Type | Details |
| --- | --- | --- | --- | --- |
| `access_level_id` | path | Yes | string | format: "uuid". |

| Response | Meaning |
| --- | --- |
| 200 | Access level. |
| 401 | The required bearer credential is missing, invalid, or revoked. |
| 404 | The app-scoped resource does not exist. |
| 503 | A verifier or durable dependency is temporarily unavailable. |

**200 application/json body:** [CatalogAccessLevel](#api-reference--catalogaccesslevel)
<a id="api-reference--patch-v1access-levelsaccesslevelid"></a>

### PATCH /v1/access-levels/{access_level_id}

Operation: `updateAccessLevel`. Updates display_name; lookup_key is immutable.

Authentication: `bearerAPIKey`

| Parameter | Location | Required | Type | Details |
| --- | --- | --- | --- | --- |
| `access_level_id` | path | Yes | string | format: "uuid". |

Request body: required.

Content type: `application/json`. 
```json
{
  "type": "object",
  "required": [
    "display_name"
  ],
  "properties": {
    "display_name": {
      "type": "string",
      "maxLength": 512
    }
  },
  "additionalProperties": false
}
```



| Response | Meaning |
| --- | --- |
| 200 | Access level updated. |
| 400 | Payload is malformed or fails store validation. |
| 401 | The required bearer credential is missing, invalid, or revoked. |
| 404 | The app-scoped resource does not exist. |
| 503 | A verifier or durable dependency is temporarily unavailable. |

**200 application/json body:** [CatalogAccessLevel](#api-reference--catalogaccesslevel)
<a id="api-reference--delete-v1access-levelsaccesslevelid"></a>

### DELETE /v1/access-levels/{access_level_id}

Operation: `archiveAccessLevel`. Archives the lookup key and ends active product access mappings without deleting replay history.

Authentication: `bearerAPIKey`

| Parameter | Location | Required | Type | Details |
| --- | --- | --- | --- | --- |
| `access_level_id` | path | Yes | string | format: "uuid". |

| Response | Meaning |
| --- | --- |
| 204 | Access level archived. |
| 401 | The required bearer credential is missing, invalid, or revoked. |
| 404 | The app-scoped resource does not exist. |
| 503 | A verifier or durable dependency is temporarily unavailable. |
<a id="api-reference--get-v1product-access-mappings"></a>

### GET /v1/product-access-mappings

Operation: `listProductAccessMappings`. 

Authentication: `bearerAPIKey`


| Response | Meaning |
| --- | --- |
| 200 | Active Apple and Google product access mappings. |
| 401 | The required bearer credential is missing, invalid, or revoked. |
| 503 | A verifier or durable dependency is temporarily unavailable. |

**200 application/json body:** 
```json
{
  "type": "object",
  "required": [
    "product_mappings"
  ],
  "properties": {
    "product_mappings": {
      "type": "array",
      "items": {
        "$ref": "#/components/schemas/ProductAccessMapping"
      }
    }
  },
  "additionalProperties": false
}
```

<a id="api-reference--post-v1product-access-mappings"></a>

### POST /v1/product-access-mappings

Operation: `createProductAccessMapping`. Starts a mapping after a short, safe ledger high-water boundary.

Authentication: `bearerAPIKey`


Request body: required.

Content type: `application/json`. [CreateProductAccessMapping](#api-reference--createproductaccessmapping)


| Response | Meaning |
| --- | --- |
| 201 | Active mapping version. |
| 400 | Payload is malformed or fails store validation. |
| 401 | The required bearer credential is missing, invalid, or revoked. |
| 404 | The app-scoped resource does not exist. |
| 409 | The requested identifier or lifecycle transition conflicts with existing state. |
| 503 | A verifier or durable dependency is temporarily unavailable. |

**201 application/json body:** [ProductAccessMapping](#api-reference--productaccessmapping)
<a id="api-reference--patch-v1product-access-mappingsmappingid"></a>

### PATCH /v1/product-access-mappings/{mapping_id}

Operation: `updateProductAccessMapping`. Ends the active version and starts a new grant-mode version at one ledger boundary.

Authentication: `bearerAPIKey`

| Parameter | Location | Required | Type | Details |
| --- | --- | --- | --- | --- |
| `mapping_id` | path | Yes | string | format: "uuid". |

Request body: required.

Content type: `application/json`. 
```json
{
  "type": "object",
  "required": [
    "grant_mode"
  ],
  "properties": {
    "grant_mode": {
      "$ref": "#/components/schemas/GrantMode"
    }
  },
  "additionalProperties": false
}
```



| Response | Meaning |
| --- | --- |
| 200 | New active mapping version. |
| 400 | Payload is malformed or fails store validation. |
| 401 | The required bearer credential is missing, invalid, or revoked. |
| 404 | The app-scoped resource does not exist. |
| 409 | The requested identifier or lifecycle transition conflicts with existing state. |
| 503 | A verifier or durable dependency is temporarily unavailable. |

**200 application/json body:** [ProductAccessMapping](#api-reference--productaccessmapping)
<a id="api-reference--delete-v1product-access-mappingsmappingid"></a>

### DELETE /v1/product-access-mappings/{mapping_id}

Operation: `deleteProductAccessMapping`. Ends the active version after the current safe ledger head.

Authentication: `bearerAPIKey`

| Parameter | Location | Required | Type | Details |
| --- | --- | --- | --- | --- |
| `mapping_id` | path | Yes | string | format: "uuid". |

| Response | Meaning |
| --- | --- |
| 204 | Mapping version ended. |
| 401 | The required bearer credential is missing, invalid, or revoked. |
| 404 | The app-scoped resource does not exist. |
| 503 | A verifier or durable dependency is temporarily unavailable. |
<a id="api-reference--post-v1client-tokens"></a>

### POST /v1/client-tokens

Operation: `createClientToken`. Requires a secret app key. Mints an opaque, short-lived token binding a publishable-key client to one app_user_id.

Authentication: `bearerAPIKey`


Request body: required.

Content type: `application/json`. [CreateClientToken](#api-reference--createclienttoken)


| Response | Meaning |
| --- | --- |
| 201 | One-time opaque customer token. |
| 400 | Payload is malformed or fails store validation. |
| 401 | The required bearer credential is missing, invalid, or revoked. |
| 403 | The authenticated key or customer token is not authorized for this resource. |
| 503 | A verifier or durable dependency is temporarily unavailable. |

**201 application/json body:** [CreatedClientToken](#api-reference--createdclienttoken)

Response headers: `Cache-Control` (const: "no-store".).
<a id="api-reference--delete-v1client-tokenstokenid"></a>

### DELETE /v1/client-tokens/{token_id}

Operation: `revokeClientToken`. Requires a secret app key and revokes one app-scoped customer token immediately.

Authentication: `bearerAPIKey`

| Parameter | Location | Required | Type | Details |
| --- | --- | --- | --- | --- |
| `token_id` | path | Yes | string | format: "uuid". |

| Response | Meaning |
| --- | --- |
| 204 | Client token revoked. |
| 401 | The required bearer credential is missing, invalid, or revoked. |
| 403 | The authenticated key or customer token is not authorized for this resource. |
| 404 | The app-scoped resource does not exist. |
| 503 | A verifier or durable dependency is temporarily unavailable. |

<a id="api-reference--webhook-management-and-delivery"></a>

## Webhook management and delivery

<a id="api-reference--get-v1webhook-endpoints"></a>

### GET /v1/webhook-endpoints

Operation: `listWebhookEndpoints`. 

Authentication: `bearerAPIKey`


| Response | Meaning |
| --- | --- |
| 200 | Non-deleted endpoints for the authenticated app. |
| 401 | The required bearer credential is missing, invalid, or revoked. |
| 503 | A verifier or durable dependency is temporarily unavailable. |

**200 application/json body:** 
```json
{
  "type": "object",
  "required": [
    "endpoints"
  ],
  "properties": {
    "endpoints": {
      "type": "array",
      "items": {
        "$ref": "#/components/schemas/WebhookEndpoint"
      }
    }
  },
  "additionalProperties": false
}
```

<a id="api-reference--post-v1webhook-endpoints"></a>

### POST /v1/webhook-endpoints

Operation: `createWebhookEndpoint`. Creates an HTTPS endpoint and returns its Standard Webhooks signing secret once. Destinations that are literal private or special-purpose addresses are rejected; resolved addresses are checked again on every dial.

Authentication: `bearerAPIKey`


Request body: required.

Content type: `application/json`. [CreateWebhookEndpoint](#api-reference--createwebhookendpoint)


| Response | Meaning |
| --- | --- |
| 201 | Endpoint and one-time signing secret. The response must not be cached. |
| 400 | Payload is malformed or fails store validation. |
| 401 | The required bearer credential is missing, invalid, or revoked. |
| 503 | A verifier or durable dependency is temporarily unavailable. |

**201 application/json body:** [CreatedWebhookEndpoint](#api-reference--createdwebhookendpoint)

Response headers: `Cache-Control` (const: "no-store".).
<a id="api-reference--get-v1webhook-endpointsendpointid"></a>

### GET /v1/webhook-endpoints/{endpoint_id}

Operation: `getWebhookEndpoint`. 

Authentication: `bearerAPIKey`

| Parameter | Location | Required | Type | Details |
| --- | --- | --- | --- | --- |
| `endpoint_id` | path | Yes | string | format: "uuid". |

| Response | Meaning |
| --- | --- |
| 200 | Endpoint configuration without signing material. |
| 401 | The required bearer credential is missing, invalid, or revoked. |
| 404 | The app-scoped resource does not exist. |
| 503 | A verifier or durable dependency is temporarily unavailable. |

**200 application/json body:** [WebhookEndpoint](#api-reference--webhookendpoint)
<a id="api-reference--patch-v1webhook-endpointsendpointid"></a>

### PATCH /v1/webhook-endpoints/{endpoint_id}

Operation: `updateWebhookEndpoint`. 

Authentication: `bearerAPIKey`

| Parameter | Location | Required | Type | Details |
| --- | --- | --- | --- | --- |
| `endpoint_id` | path | Yes | string | format: "uuid". |

Request body: required.

Content type: `application/json`. [UpdateWebhookEndpoint](#api-reference--updatewebhookendpoint)


| Response | Meaning |
| --- | --- |
| 200 | Updated endpoint. |
| 400 | Payload is malformed or fails store validation. |
| 401 | The required bearer credential is missing, invalid, or revoked. |
| 404 | The app-scoped resource does not exist. |
| 503 | A verifier or durable dependency is temporarily unavailable. |

**200 application/json body:** [WebhookEndpoint](#api-reference--webhookendpoint)
<a id="api-reference--delete-v1webhook-endpointsendpointid"></a>

### DELETE /v1/webhook-endpoints/{endpoint_id}

Operation: `deleteWebhookEndpoint`. Soft-deletes the endpoint, cancels queued work, and retains delivery history.

Authentication: `bearerAPIKey`

| Parameter | Location | Required | Type | Details |
| --- | --- | --- | --- | --- |
| `endpoint_id` | path | Yes | string | format: "uuid". |

| Response | Meaning |
| --- | --- |
| 204 | Endpoint deleted. |
| 401 | The required bearer credential is missing, invalid, or revoked. |
| 404 | The app-scoped resource does not exist. |
| 503 | A verifier or durable dependency is temporarily unavailable. |
<a id="api-reference--post-v1webhook-endpointsendpointidrotate-secret"></a>

### POST /v1/webhook-endpoints/{endpoint_id}/rotate-secret

Operation: `rotateWebhookEndpointSecret`. Returns a new signing secret once and dual-signs with the immediately previous secret for 24 hours.

Authentication: `bearerAPIKey`

| Parameter | Location | Required | Type | Details |
| --- | --- | --- | --- | --- |
| `endpoint_id` | path | Yes | string | format: "uuid". |

| Response | Meaning |
| --- | --- |
| 200 | One-time rotated secret and overlap boundary. |
| 401 | The required bearer credential is missing, invalid, or revoked. |
| 404 | The app-scoped resource does not exist. |
| 503 | A verifier or durable dependency is temporarily unavailable. |

**200 application/json body:** [RotatedWebhookSecret](#api-reference--rotatedwebhooksecret)

Response headers: `Cache-Control` (const: "no-store".).
<a id="api-reference--get-v1webhook-deliveries"></a>

### GET /v1/webhook-deliveries

Operation: `listWebhookDeliveries`. 

Authentication: `bearerAPIKey`

| Parameter | Location | Required | Type | Details |
| --- | --- | --- | --- | --- |
| `endpoint_id` | query | No | string | format: "uuid". |
| `status` | query | No | [WebhookDeliveryStatus](#api-reference--webhookdeliverystatus) |  |
| `before_event_number` | query | No | integer | Exclusive keyset cursor over the monotonic, non-gapless ledger event number. format: "int64". minimum: 1. |
| `limit` | query | No | integer | minimum: 1. maximum: 200. default: 50. |

| Response | Meaning |
| --- | --- |
| 200 | Newest-first delivery history. |
| 400 | Payload is malformed or fails store validation. |
| 401 | The required bearer credential is missing, invalid, or revoked. |
| 503 | A verifier or durable dependency is temporarily unavailable. |

**200 application/json body:** 
```json
{
  "type": "object",
  "required": [
    "deliveries"
  ],
  "properties": {
    "deliveries": {
      "type": "array",
      "items": {
        "$ref": "#/components/schemas/WebhookDelivery"
      }
    }
  },
  "additionalProperties": false
}
```

<a id="api-reference--get-v1webhook-deliveriesmessageid"></a>

### GET /v1/webhook-deliveries/{message_id}

Operation: `getWebhookDelivery`. 

Authentication: `bearerAPIKey`

| Parameter | Location | Required | Type | Details |
| --- | --- | --- | --- | --- |
| `message_id` | path | Yes | string | pattern: "^msg_[A-Za-z0-9_-]{20,64}$". |

| Response | Meaning |
| --- | --- |
| 200 | Delivery and every recorded attempt, including expired worker leases. |
| 401 | The required bearer credential is missing, invalid, or revoked. |
| 404 | The app-scoped resource does not exist. |
| 503 | A verifier or durable dependency is temporarily unavailable. |

**200 application/json body:** [WebhookDeliveryDetail](#api-reference--webhookdeliverydetail)
<a id="api-reference--post-v1webhook-deliveriesmessageidretry"></a>

### POST /v1/webhook-deliveries/{message_id}/retry

Operation: `retryWebhookDelivery`. Starts a fresh 72-hour retry cycle for a terminal delivery while retaining its message id and prior attempt history. The endpoint must first be enabled.

Authentication: `bearerAPIKey`

| Parameter | Location | Required | Type | Details |
| --- | --- | --- | --- | --- |
| `message_id` | path | Yes | string | pattern: "^msg_[A-Za-z0-9_-]{20,64}$". |

| Response | Meaning |
| --- | --- |
| 200 | Requeued delivery. |
| 400 | Payload is malformed or fails store validation. |
| 401 | The required bearer credential is missing, invalid, or revoked. |
| 404 | The app-scoped resource does not exist. |
| 409 | Endpoint is disabled or delivery is already queued/in flight. |
| 503 | A verifier or durable dependency is temporarily unavailable. |

**200 application/json body:** [WebhookDelivery](#api-reference--webhookdelivery)

<a id="api-reference--root-operator-administration"></a>

## Root operator administration

<a id="api-reference--get-adminv1apps"></a>

### GET /admin/v1/apps

Operation: `listApps`. 

Authentication: `adminBearer`


| Response | Meaning |
| --- | --- |
| 200 | All active and disabled apps in stable creation order. |
| 401 | The required bearer credential is missing, invalid, or revoked. |
| 503 | A verifier or durable dependency is temporarily unavailable. |

**200 application/json body:** 
```json
{
  "type": "object",
  "required": [
    "apps"
  ],
  "properties": {
    "apps": {
      "type": "array",
      "items": {
        "$ref": "#/components/schemas/App"
      }
    }
  },
  "additionalProperties": false
}
```

<a id="api-reference--post-adminv1apps"></a>

### POST /admin/v1/apps

Operation: `createApp`. Creates an Apple, Google, or cross-platform app and returns its first API key exactly once.

Authentication: `adminBearer`


Request body: required.

Content type: `application/json`. [CreateApp](#api-reference--createapp)


| Response | Meaning |
| --- | --- |
| 201 | App, safe key metadata, and one-time plaintext secret. |
| 400 | Payload is malformed or fails store validation. |
| 401 | The required bearer credential is missing, invalid, or revoked. |
| 409 | The requested identifier or lifecycle transition conflicts with existing state. |
| 503 | A verifier or durable dependency is temporarily unavailable. |

**201 application/json body:** [CreatedApp](#api-reference--createdapp)

Response headers: `Cache-Control` (const: "no-store".).
<a id="api-reference--get-adminv1appsappid"></a>

### GET /admin/v1/apps/{app_id}

Operation: `getApp`. 

Authentication: `adminBearer`

| Parameter | Location | Required | Type | Details |
| --- | --- | --- | --- | --- |
| `app_id` | path | Yes | string | format: "uuid". |

| Response | Meaning |
| --- | --- |
| 200 | App configuration. |
| 401 | The required bearer credential is missing, invalid, or revoked. |
| 404 | The app-scoped resource does not exist. |
| 503 | A verifier or durable dependency is temporarily unavailable. |

**200 application/json body:** [App](#api-reference--app)
<a id="api-reference--patch-adminv1appsappid"></a>

### PATCH /admin/v1/apps/{app_id}

Operation: `updateApp`. 

Authentication: `adminBearer`

| Parameter | Location | Required | Type | Details |
| --- | --- | --- | --- | --- |
| `app_id` | path | Yes | string | format: "uuid". |

Request body: required.

Content type: `application/json`. [UpdateApp](#api-reference--updateapp)


| Response | Meaning |
| --- | --- |
| 200 | Updated app. Re-enabling does not un-revoke old keys. |
| 400 | Payload is malformed or fails store validation. |
| 401 | The required bearer credential is missing, invalid, or revoked. |
| 404 | The app-scoped resource does not exist. |
| 409 | The requested identifier or lifecycle transition conflicts with existing state. |
| 503 | A verifier or durable dependency is temporarily unavailable. |

**200 application/json body:** [App](#api-reference--app)
<a id="api-reference--delete-adminv1appsappid"></a>

### DELETE /admin/v1/apps/{app_id}

Operation: `disableApp`. Soft-disables ingestion, revokes all app API keys, disables webhooks, and retains audit history.

Authentication: `adminBearer`

| Parameter | Location | Required | Type | Details |
| --- | --- | --- | --- | --- |
| `app_id` | path | Yes | string | format: "uuid". |

| Response | Meaning |
| --- | --- |
| 204 | App disabled. |
| 401 | The required bearer credential is missing, invalid, or revoked. |
| 404 | The app-scoped resource does not exist. |
| 503 | A verifier or durable dependency is temporarily unavailable. |
<a id="api-reference--get-adminv1appsappidapi-keys"></a>

### GET /admin/v1/apps/{app_id}/api-keys

Operation: `listAppAPIKeys`. 

Authentication: `adminBearer`

| Parameter | Location | Required | Type | Details |
| --- | --- | --- | --- | --- |
| `app_id` | path | Yes | string | format: "uuid". |

| Response | Meaning |
| --- | --- |
| 200 | Safe API-key metadata; plaintext keys are never returned. |
| 401 | The required bearer credential is missing, invalid, or revoked. |
| 404 | The app-scoped resource does not exist. |
| 503 | A verifier or durable dependency is temporarily unavailable. |

**200 application/json body:** 
```json
{
  "type": "object",
  "required": [
    "api_keys"
  ],
  "properties": {
    "api_keys": {
      "type": "array",
      "items": {
        "$ref": "#/components/schemas/APIKey"
      }
    }
  },
  "additionalProperties": false
}
```

<a id="api-reference--post-adminv1appsappidapi-keys"></a>

### POST /admin/v1/apps/{app_id}/api-keys

Operation: `createAppAPIKey`. 

Authentication: `adminBearer`

| Parameter | Location | Required | Type | Details |
| --- | --- | --- | --- | --- |
| `app_id` | path | Yes | string | format: "uuid". |

Request body: required.

Content type: `application/json`. 
```json
{
  "type": "object",
  "properties": {
    "label": {
      "type": "string",
      "maxLength": 512
    },
    "kind": {
      "type": "string",
      "enum": [
        "secret",
        "publishable"
      ],
      "default": "secret"
    }
  },
  "additionalProperties": false
}
```



| Response | Meaning |
| --- | --- |
| 201 | Safe metadata and one-time plaintext secret. |
| 400 | Payload is malformed or fails store validation. |
| 401 | The required bearer credential is missing, invalid, or revoked. |
| 404 | The app-scoped resource does not exist. |
| 409 | The requested identifier or lifecycle transition conflicts with existing state. |
| 503 | A verifier or durable dependency is temporarily unavailable. |

**201 application/json body:** [CreatedAPIKey](#api-reference--createdapikey)

Response headers: `Cache-Control` (const: "no-store".).
<a id="api-reference--delete-adminv1appsappidapi-keyskeyid"></a>

### DELETE /admin/v1/apps/{app_id}/api-keys/{key_id}

Operation: `revokeAppAPIKey`. 

Authentication: `adminBearer`

| Parameter | Location | Required | Type | Details |
| --- | --- | --- | --- | --- |
| `app_id` | path | Yes | string | format: "uuid". |
| `key_id` | path | Yes | string | format: "uuid". |

| Response | Meaning |
| --- | --- |
| 204 | Key revoked. |
| 401 | The required bearer credential is missing, invalid, or revoked. |
| 404 | The app-scoped resource does not exist. |
| 503 | A verifier or durable dependency is temporarily unavailable. |

<a id="api-reference--incoming-store-notifications"></a>

## Incoming store notifications

<a id="api-reference--post-ingestappleassn"></a>

### POST /ingest/apple/assn

Operation: `ingestAppleNotification`. Verifies an App Store Server Notifications V2 outer and nested JWS values, attributes its app, and atomically appends and projects or rejects it.

Authentication: No app-key security scheme declared; store-ingress verification still applies where described.


Request body: required.

Content type: `application/json`. 
```json
{
  "type": "object",
  "required": [
    "signedPayload"
  ],
  "properties": {
    "signedPayload": {
      "type": "string"
    }
  },
  "additionalProperties": false
}
```



| Response | Meaning |
| --- | --- |
| 200 | Delivery appended, deduplicated, or durably rejected. |
| 503 | PostgreSQL or Apple's OCSP responder is temporarily unavailable. |
<a id="api-reference--post-ingestgooglertdn"></a>

### POST /ingest/google/rtdn

Operation: `ingestGoogleNotification`. Verifies Pub/Sub OIDC identity, appends the RTDN doorbell, fetches authoritative subscriptionsv2 or productsv2 state when required, and atomically projects all line items.

Authentication: `googlePubSubOIDC`


Request body: required.

Content type: `application/json`. [PubSubPush](#api-reference--pubsubpush)


| Response | Meaning |
| --- | --- |
| 200 | Delivery appended, deduplicated, or durably rejected. |
| 401 | The required bearer credential is missing, invalid, or revoked. |
| 403 | The valid Google identity is not the configured Pub/Sub service account. |
| 503 | A verifier or durable dependency is temporarily unavailable. |

<a id="api-reference--health-and-observability"></a>

## Health and observability

<a id="api-reference--get-livez"></a>

### GET /livez

Operation: `getLiveness`. 

Authentication: No app-key security scheme declared; store-ingress verification still applies where described.


| Response | Meaning |
| --- | --- |
| 200 | Process is alive. |

**200 application/json body:** [Probe](#api-reference--probe)
<a id="api-reference--get-readyz"></a>

### GET /readyz

Operation: `getReadiness`. 

Authentication: No app-key security scheme declared; store-ingress verification still applies where described.


| Response | Meaning |
| --- | --- |
| 200 | Process and PostgreSQL are ready. |
| 503 | PostgreSQL is unavailable. |

**200 application/json body:** [Probe](#api-reference--probe)
<a id="api-reference--get-healthz"></a>

### GET /healthz

Operation: `getHealth`. Compatibility alias for readiness.

Authentication: No app-key security scheme declared; store-ingress verification still applies where described.


| Response | Meaning |
| --- | --- |
| 200 | Ready. |
| 503 | Not ready. |

**200 application/json body:** [Probe](#api-reference--probe)
<a id="api-reference--get-metrics"></a>

### GET /metrics

Operation: `getMetrics`. 

Authentication: No app-key security scheme declared; store-ingress verification still applies where described.


| Response | Meaning |
| --- | --- |
| 200 | Prometheus exposition. |

**200 text/plain body:** 
```json
{
  "type": "string"
}
```


<a id="api-reference--authentication-schemes"></a>

## Authentication schemes

<a id="api-reference--googlepubsuboidc"></a>

### googlePubSubOIDC



```json
{
  "type": "http",
  "scheme": "bearer",
  "bearerFormat": "Google OIDC ID token"
}
```

<a id="api-reference--bearerapikey"></a>

### bearerAPIKey



```json
{
  "type": "http",
  "scheme": "bearer",
  "bearerFormat": "iap_sk_ secret or iap_pk_ publishable app key"
}
```

<a id="api-reference--adminbearer"></a>

### adminBearer



```json
{
  "type": "http",
  "scheme": "bearer",
  "bearerFormat": "root admin API key"
}
```


<a id="api-reference--request-and-response-models"></a>

## Request and response models

Fields are listed below. Nested array/object definitions, alternatives, and conditional rules remain in the complete schema following each model when applicable. `writeOnly` denotes one-time/input secret material; do not assume it can be read back from management lists.

<a id="api-reference--probe"></a>

### Probe



| Field | Required | Type | Details |
| --- | --- | --- | --- |
| `status` | Yes | string | Values: ok, unavailable. |

```json
{
  "type": "object",
  "required": [
    "status"
  ],
  "properties": {
    "status": {
      "type": "string",
      "enum": [
        "ok",
        "unavailable"
      ]
    }
  },
  "additionalProperties": false
}
```

<a id="api-reference--pubsubpush"></a>

### PubSubPush



| Field | Required | Type | Details |
| --- | --- | --- | --- |
| `message` | Yes | object |  |
| `subscription` | No | string |  |

```json
{
  "type": "object",
  "required": [
    "message"
  ],
  "properties": {
    "message": {
      "type": "object",
      "required": [
        "data",
        "messageId"
      ],
      "properties": {
        "data": {
          "type": "string",
          "contentEncoding": "base64"
        },
        "messageId": {
          "type": "string"
        },
        "publishTime": {
          "type": "string",
          "format": "date-time"
        },
        "attributes": {
          "type": "object",
          "additionalProperties": {
            "type": "string"
          }
        }
      },
      "additionalProperties": true
    },
    "subscription": {
      "type": "string"
    }
  },
  "additionalProperties": true
}
```

<a id="api-reference--receiptsubmission"></a>

### ReceiptSubmission



| Field | Required | Type | Details |
| --- | --- | --- | --- |
| `platform` | Yes | string | Values: apple, google. |
| `app_user_id` | Yes | string | minLength: 1. maxLength: 512. |
| `product_type` | No | string | Required for Google; omitted for Apple. Values: subscription, one_time_product. |
| `store_payload` | Yes | string | Apple StoreKit transaction jwsRepresentation or Google purchase token. |

```json
{
  "type": "object",
  "required": [
    "platform",
    "app_user_id",
    "store_payload"
  ],
  "properties": {
    "platform": {
      "type": "string",
      "enum": [
        "apple",
        "google"
      ]
    },
    "app_user_id": {
      "type": "string",
      "minLength": 1,
      "maxLength": 512
    },
    "product_type": {
      "type": "string",
      "enum": [
        "subscription",
        "one_time_product"
      ],
      "description": "Required for Google; omitted for Apple."
    },
    "store_payload": {
      "type": "string",
      "description": "Apple StoreKit transaction jwsRepresentation or Google purchase token."
    }
  },
  "additionalProperties": false,
  "allOf": [
    {
      "if": {
        "properties": {
          "platform": {
            "const": "google"
          }
        }
      },
      "then": {
        "required": [
          "product_type"
        ]
      }
    }
  ]
}
```

<a id="api-reference--ingestresult"></a>

### IngestResult



| Field | Required | Type | Details |
| --- | --- | --- | --- |
| `event_id` | Yes | string | Positive decimal int64 ledger id (maximum 9223372036854775807) encoded as a string for lossless JavaScript and Dart interoperability. maxLength: 19. pattern: "^[1-9][0-9]*$". |
| `deduplicated` | Yes | boolean |  |

```json
{
  "type": "object",
  "required": [
    "event_id",
    "deduplicated"
  ],
  "properties": {
    "event_id": {
      "type": "string",
      "pattern": "^[1-9][0-9]*$",
      "maxLength": 19,
      "description": "Positive decimal int64 ledger id (maximum 9223372036854775807) encoded as a string for lossless JavaScript and Dart interoperability."
    },
    "deduplicated": {
      "type": "boolean"
    }
  },
  "additionalProperties": false
}
```

<a id="api-reference--subscriber"></a>

### Subscriber



| Field | Required | Type | Details |
| --- | --- | --- | --- |
| `app_user_id` | Yes | string |  |
| `customer_id` | Yes | string | format: "uuid". |
| `access_levels` | Yes | object |  |

```json
{
  "type": "object",
  "required": [
    "app_user_id",
    "customer_id",
    "access_levels"
  ],
  "properties": {
    "app_user_id": {
      "type": "string"
    },
    "customer_id": {
      "type": "string",
      "format": "uuid"
    },
    "access_levels": {
      "type": "object",
      "additionalProperties": {
        "$ref": "#/components/schemas/SubscriberAccessLevel"
      }
    }
  },
  "additionalProperties": false
}
```

<a id="api-reference--subscriberaccesslevel"></a>

### SubscriberAccessLevel



| Field | Required | Type | Details |
| --- | --- | --- | --- |
| `active` | Yes | boolean |  |
| `expires_at` | No | string | format: "date-time". |
| `platform` | Yes | string | Values: apple, google. |
| `product_id` | Yes | string |  |
| `last_verified_at` | Yes | string | format: "date-time". |
| `event_id` | Yes | string | Positive decimal int64 ledger id (maximum 9223372036854775807) encoded as a string for lossless JavaScript and Dart interoperability. maxLength: 19. pattern: "^[1-9][0-9]*$". |

```json
{
  "type": "object",
  "required": [
    "active",
    "platform",
    "product_id",
    "last_verified_at",
    "event_id"
  ],
  "properties": {
    "active": {
      "type": "boolean"
    },
    "expires_at": {
      "type": "string",
      "format": "date-time"
    },
    "platform": {
      "type": "string",
      "enum": [
        "apple",
        "google"
      ]
    },
    "product_id": {
      "type": "string"
    },
    "last_verified_at": {
      "type": "string",
      "format": "date-time"
    },
    "event_id": {
      "type": "string",
      "pattern": "^[1-9][0-9]*$",
      "maxLength": 19,
      "description": "Positive decimal int64 ledger id (maximum 9223372036854775807) encoded as a string for lossless JavaScript and Dart interoperability."
    }
  },
  "additionalProperties": false
}
```

<a id="api-reference--currentproductset"></a>

### CurrentProductSet



| Field | Required | Type | Details |
| --- | --- | --- | --- |
| `product_set` | Yes | union |  |

```json
{
  "type": "object",
  "required": [
    "product_set"
  ],
  "properties": {
    "product_set": {
      "anyOf": [
        {
          "$ref": "#/components/schemas/ProductSet"
        },
        {
          "type": "null"
        }
      ]
    }
  },
  "additionalProperties": false
}
```

<a id="api-reference--productset"></a>

### ProductSet



| Field | Required | Type | Details |
| --- | --- | --- | --- |
| `id` | Yes | string | format: "uuid". |
| `identifier` | Yes | string | minLength: 1. maxLength: 128. pattern: "^[A-Za-z0-9._-]+$". |
| `display_name` | Yes | string | minLength: 1. maxLength: 256. |
| `packages` | Yes | array of [ProductSetPackage](#api-reference--productsetpackage) | Ordered by ascending position; identifiers and positions are unique within the set. |
| `updated_at` | Yes | string | format: "date-time". |

```json
{
  "type": "object",
  "required": [
    "id",
    "identifier",
    "display_name",
    "packages",
    "updated_at"
  ],
  "properties": {
    "id": {
      "type": "string",
      "format": "uuid"
    },
    "identifier": {
      "type": "string",
      "minLength": 1,
      "maxLength": 128,
      "pattern": "^[A-Za-z0-9._-]+$"
    },
    "display_name": {
      "type": "string",
      "minLength": 1,
      "maxLength": 256
    },
    "packages": {
      "type": "array",
      "description": "Ordered by ascending position; identifiers and positions are unique within the set.",
      "items": {
        "$ref": "#/components/schemas/ProductSetPackage"
      }
    },
    "updated_at": {
      "type": "string",
      "format": "date-time"
    }
  },
  "additionalProperties": false
}
```

<a id="api-reference--productsetpackage"></a>

### ProductSetPackage



| Field | Required | Type | Details |
| --- | --- | --- | --- |
| `id` | Yes | string | format: "uuid". |
| `identifier` | Yes | string | minLength: 1. maxLength: 128. pattern: "^[A-Za-z0-9._-]+$". |
| `package_type` | Yes | string | Values: custom, lifetime, annual, six_month, three_month, two_month, monthly, weekly. |
| `platform` | Yes | string | Values: apple, google. |
| `product_id` | Yes | string | minLength: 1. maxLength: 512. |
| `base_plan_id` | No | string | Optional Google Play base-plan identifier. Present only for Google packages; preserve it when selecting a storefront offer. minLength: 1. maxLength: 128. pattern: "^[A-Za-z0-9._-]+$". |
| `position` | Yes | integer | minimum: 0. maximum: 1000. |

```json
{
  "type": "object",
  "required": [
    "id",
    "identifier",
    "package_type",
    "platform",
    "product_id",
    "position"
  ],
  "properties": {
    "id": {
      "type": "string",
      "format": "uuid"
    },
    "identifier": {
      "type": "string",
      "minLength": 1,
      "maxLength": 128,
      "pattern": "^[A-Za-z0-9._-]+$"
    },
    "package_type": {
      "type": "string",
      "enum": [
        "custom",
        "lifetime",
        "annual",
        "six_month",
        "three_month",
        "two_month",
        "monthly",
        "weekly"
      ]
    },
    "platform": {
      "type": "string",
      "enum": [
        "apple",
        "google"
      ]
    },
    "product_id": {
      "type": "string",
      "minLength": 1,
      "maxLength": 512
    },
    "base_plan_id": {
      "type": "string",
      "minLength": 1,
      "maxLength": 128,
      "pattern": "^[A-Za-z0-9._-]+$",
      "description": "Optional Google Play base-plan identifier. Present only for Google packages; preserve it when selecting a storefront offer."
    },
    "position": {
      "type": "integer",
      "minimum": 0,
      "maximum": 1000
    }
  },
  "additionalProperties": false
}
```

<a id="api-reference--webhookendpoint"></a>

### WebhookEndpoint



| Field | Required | Type | Details |
| --- | --- | --- | --- |
| `id` | Yes | string | format: "uuid". |
| `url` | Yes | string | format: "uri". maxLength: 2048. |
| `description` | Yes | string | maxLength: 512. |
| `event_types` | Yes | array of string |  |
| `enabled` | Yes | boolean |  |
| `disabled_reason` | No | string | maxLength: 512. |
| `disabled_at` | No | string | format: "date-time". |
| `created_at` | Yes | string | format: "date-time". |
| `updated_at` | Yes | string | format: "date-time". |

```json
{
  "type": "object",
  "required": [
    "id",
    "url",
    "description",
    "event_types",
    "enabled",
    "created_at",
    "updated_at"
  ],
  "properties": {
    "id": {
      "type": "string",
      "format": "uuid"
    },
    "url": {
      "type": "string",
      "format": "uri",
      "maxLength": 2048
    },
    "description": {
      "type": "string",
      "maxLength": 512
    },
    "event_types": {
      "type": "array",
      "minItems": 1,
      "maxItems": 100,
      "uniqueItems": true,
      "items": {
        "type": "string"
      }
    },
    "enabled": {
      "type": "boolean"
    },
    "disabled_reason": {
      "type": "string",
      "maxLength": 512
    },
    "disabled_at": {
      "type": "string",
      "format": "date-time"
    },
    "created_at": {
      "type": "string",
      "format": "date-time"
    },
    "updated_at": {
      "type": "string",
      "format": "date-time"
    }
  },
  "additionalProperties": false
}
```

<a id="api-reference--createwebhookendpoint"></a>

### CreateWebhookEndpoint



| Field | Required | Type | Details |
| --- | --- | --- | --- |
| `url` | Yes | string | format: "uri". maxLength: 2048. pattern: "^https://". |
| `description` | No | string | maxLength: 512. |
| `event_types` | No | array of string | default: ["access.changed"]. |

```json
{
  "type": "object",
  "required": [
    "url"
  ],
  "properties": {
    "url": {
      "type": "string",
      "format": "uri",
      "pattern": "^https://",
      "maxLength": 2048
    },
    "description": {
      "type": "string",
      "maxLength": 512
    },
    "event_types": {
      "type": "array",
      "minItems": 1,
      "maxItems": 100,
      "uniqueItems": true,
      "default": [
        "access.changed"
      ],
      "items": {
        "type": "string"
      }
    }
  },
  "additionalProperties": false
}
```

<a id="api-reference--updatewebhookendpoint"></a>

### UpdateWebhookEndpoint



| Field | Required | Type | Details |
| --- | --- | --- | --- |
| `url` | No | string | format: "uri". maxLength: 2048. pattern: "^https://". |
| `description` | No | string | maxLength: 512. |
| `event_types` | No | array of string |  |
| `enabled` | No | boolean |  |

```json
{
  "type": "object",
  "minProperties": 1,
  "properties": {
    "url": {
      "type": "string",
      "format": "uri",
      "pattern": "^https://",
      "maxLength": 2048
    },
    "description": {
      "type": "string",
      "maxLength": 512
    },
    "event_types": {
      "type": "array",
      "minItems": 1,
      "maxItems": 100,
      "uniqueItems": true,
      "items": {
        "type": "string"
      }
    },
    "enabled": {
      "type": "boolean"
    }
  },
  "additionalProperties": false
}
```

<a id="api-reference--createdwebhookendpoint"></a>

### CreatedWebhookEndpoint



| Field | Required | Type | Details |
| --- | --- | --- | --- |
| `endpoint` | Yes | [WebhookEndpoint](#api-reference--webhookendpoint) |  |
| `secret` | Yes | string | pattern: "^whsec_". writeOnly: true. |

```json
{
  "type": "object",
  "required": [
    "endpoint",
    "secret"
  ],
  "properties": {
    "endpoint": {
      "$ref": "#/components/schemas/WebhookEndpoint"
    },
    "secret": {
      "type": "string",
      "pattern": "^whsec_",
      "writeOnly": true
    }
  },
  "additionalProperties": false
}
```

<a id="api-reference--rotatedwebhooksecret"></a>

### RotatedWebhookSecret



| Field | Required | Type | Details |
| --- | --- | --- | --- |
| `endpoint_id` | Yes | string | format: "uuid". |
| `secret` | Yes | string | pattern: "^whsec_". writeOnly: true. |
| `rotated_at` | Yes | string | format: "date-time". |
| `overlap_ends_at` | Yes | string | format: "date-time". |

```json
{
  "type": "object",
  "required": [
    "endpoint_id",
    "secret",
    "rotated_at",
    "overlap_ends_at"
  ],
  "properties": {
    "endpoint_id": {
      "type": "string",
      "format": "uuid"
    },
    "secret": {
      "type": "string",
      "pattern": "^whsec_",
      "writeOnly": true
    },
    "rotated_at": {
      "type": "string",
      "format": "date-time"
    },
    "overlap_ends_at": {
      "type": "string",
      "format": "date-time"
    }
  },
  "additionalProperties": false
}
```

<a id="api-reference--webhookdeliverystatus"></a>

### WebhookDeliveryStatus




```json
{
  "type": "string",
  "enum": [
    "pending",
    "in_flight",
    "retry",
    "succeeded",
    "dead_letter",
    "canceled"
  ]
}
```

<a id="api-reference--webhookdelivery"></a>

### WebhookDelivery



| Field | Required | Type | Details |
| --- | --- | --- | --- |
| `message_id` | Yes | string | pattern: "^msg_[A-Za-z0-9_-]{20,64}$". |
| `endpoint_id` | Yes | string | format: "uuid". |
| `event_number` | Yes | integer | format: "int64". |
| `event_type` | Yes | string |  |
| `status` | Yes | [WebhookDeliveryStatus](#api-reference--webhookdeliverystatus) |  |
| `attempt_count` | Yes | integer | minimum: 0. |
| `next_attempt_at` | No | string | format: "date-time". |
| `first_attempt_at` | No | string | format: "date-time". |
| `last_attempt_at` | No | string | format: "date-time". |
| `succeeded_at` | No | string | format: "date-time". |
| `last_http_status` | No | integer | minimum: 100. maximum: 599. |
| `last_error` | No | string | maxLength: 2048. |
| `last_response_body` | No | string | UTF-8-safe preview capped at 64 KiB. |
| `cycle_started_at` | Yes | string | format: "date-time". |
| `created_at` | Yes | string | format: "date-time". |
| `updated_at` | Yes | string | format: "date-time". |

```json
{
  "type": "object",
  "required": [
    "message_id",
    "endpoint_id",
    "event_number",
    "event_type",
    "status",
    "attempt_count",
    "cycle_started_at",
    "created_at",
    "updated_at"
  ],
  "properties": {
    "message_id": {
      "type": "string",
      "pattern": "^msg_[A-Za-z0-9_-]{20,64}$"
    },
    "endpoint_id": {
      "type": "string",
      "format": "uuid"
    },
    "event_number": {
      "type": "integer",
      "format": "int64"
    },
    "event_type": {
      "type": "string"
    },
    "status": {
      "$ref": "#/components/schemas/WebhookDeliveryStatus"
    },
    "attempt_count": {
      "type": "integer",
      "minimum": 0
    },
    "next_attempt_at": {
      "type": "string",
      "format": "date-time"
    },
    "first_attempt_at": {
      "type": "string",
      "format": "date-time"
    },
    "last_attempt_at": {
      "type": "string",
      "format": "date-time"
    },
    "succeeded_at": {
      "type": "string",
      "format": "date-time"
    },
    "last_http_status": {
      "type": "integer",
      "minimum": 100,
      "maximum": 599
    },
    "last_error": {
      "type": "string",
      "maxLength": 2048
    },
    "last_response_body": {
      "type": "string",
      "description": "UTF-8-safe preview capped at 64 KiB."
    },
    "cycle_started_at": {
      "type": "string",
      "format": "date-time"
    },
    "created_at": {
      "type": "string",
      "format": "date-time"
    },
    "updated_at": {
      "type": "string",
      "format": "date-time"
    }
  },
  "additionalProperties": false
}
```

<a id="api-reference--webhookdeliveryattempt"></a>

### WebhookDeliveryAttempt



| Field | Required | Type | Details |
| --- | --- | --- | --- |
| `attempt_number` | Yes | integer | minimum: 1. |
| `started_at` | Yes | string | format: "date-time". |
| `finished_at` | No | string | format: "date-time". |
| `http_status` | No | integer | minimum: 100. maximum: 599. |
| `error` | No | string | maxLength: 2048. |
| `response_body` | No | string |  |
| `duration_ms` | No | integer | format: "int64". minimum: 0. |
| `succeeded` | No | boolean |  |

```json
{
  "type": "object",
  "required": [
    "attempt_number",
    "started_at"
  ],
  "properties": {
    "attempt_number": {
      "type": "integer",
      "minimum": 1
    },
    "started_at": {
      "type": "string",
      "format": "date-time"
    },
    "finished_at": {
      "type": "string",
      "format": "date-time"
    },
    "http_status": {
      "type": "integer",
      "minimum": 100,
      "maximum": 599
    },
    "error": {
      "type": "string",
      "maxLength": 2048
    },
    "response_body": {
      "type": "string"
    },
    "duration_ms": {
      "type": "integer",
      "format": "int64",
      "minimum": 0
    },
    "succeeded": {
      "type": "boolean"
    }
  },
  "additionalProperties": false
}
```

<a id="api-reference--webhookdeliverydetail"></a>

### WebhookDeliveryDetail



| Field | Required | Type | Details |
| --- | --- | --- | --- |
| `delivery` | Yes | [WebhookDelivery](#api-reference--webhookdelivery) |  |
| `attempts` | Yes | array of [WebhookDeliveryAttempt](#api-reference--webhookdeliveryattempt) |  |

```json
{
  "type": "object",
  "required": [
    "delivery",
    "attempts"
  ],
  "properties": {
    "delivery": {
      "$ref": "#/components/schemas/WebhookDelivery"
    },
    "attempts": {
      "type": "array",
      "items": {
        "$ref": "#/components/schemas/WebhookDeliveryAttempt"
      }
    }
  },
  "additionalProperties": false
}
```

<a id="api-reference--outboundwebhookevent"></a>

### OutboundWebhookEvent

Exact JSON body signed using Standard Webhooks headers.

| Field | Required | Type | Details |
| --- | --- | --- | --- |
| `id` | Yes | string | pattern: "^evt_[0-9]+$". |
| `type` | Yes | string |  |
| `api_version` | Yes | string | const: "2026-08-28". |
| `timestamp` | Yes | string | format: "date-time". |
| `event_number` | Yes | integer | Monotonic but not gapless ledger identifier. format: "int64". |
| `data` | Yes | object |  |

```json
{
  "type": "object",
  "description": "Exact JSON body signed using Standard Webhooks headers.",
  "required": [
    "id",
    "type",
    "api_version",
    "timestamp",
    "event_number",
    "data"
  ],
  "properties": {
    "id": {
      "type": "string",
      "pattern": "^evt_[0-9]+$"
    },
    "type": {
      "type": "string",
      "example": "access.changed"
    },
    "api_version": {
      "type": "string",
      "const": "2026-08-28"
    },
    "timestamp": {
      "type": "string",
      "format": "date-time"
    },
    "event_number": {
      "type": "integer",
      "format": "int64",
      "description": "Monotonic but not gapless ledger identifier."
    },
    "data": {
      "type": "object",
      "additionalProperties": true
    }
  },
  "additionalProperties": false
}
```

<a id="api-reference--app"></a>

### App



| Field | Required | Type | Details |
| --- | --- | --- | --- |
| `id` | Yes | string | format: "uuid". |
| `name` | Yes | string | minLength: 1. maxLength: 512. |
| `apple_bundle_id` | No | string | minLength: 1. maxLength: 512. |
| `apple_app_apple_id` | No | integer | format: "int64". minimum: 1. |
| `google_package_name` | No | string | minLength: 1. maxLength: 512. |
| `enabled` | Yes | boolean |  |
| `disabled_at` | No | string | format: "date-time". |
| `created_at` | Yes | string | format: "date-time". |
| `updated_at` | Yes | string | format: "date-time". |

```json
{
  "type": "object",
  "required": [
    "id",
    "name",
    "enabled",
    "created_at",
    "updated_at"
  ],
  "properties": {
    "id": {
      "type": "string",
      "format": "uuid"
    },
    "name": {
      "type": "string",
      "minLength": 1,
      "maxLength": 512
    },
    "apple_bundle_id": {
      "type": "string",
      "minLength": 1,
      "maxLength": 512
    },
    "apple_app_apple_id": {
      "type": "integer",
      "format": "int64",
      "minimum": 1
    },
    "google_package_name": {
      "type": "string",
      "minLength": 1,
      "maxLength": 512
    },
    "enabled": {
      "type": "boolean"
    },
    "disabled_at": {
      "type": "string",
      "format": "date-time"
    },
    "created_at": {
      "type": "string",
      "format": "date-time"
    },
    "updated_at": {
      "type": "string",
      "format": "date-time"
    }
  },
  "additionalProperties": false
}
```

<a id="api-reference--createapp"></a>

### CreateApp



| Field | Required | Type | Details |
| --- | --- | --- | --- |
| `name` | Yes | string | minLength: 1. maxLength: 512. |
| `apple_bundle_id` | No | string | minLength: 1. maxLength: 512. |
| `apple_app_apple_id` | No | integer | Required for production Apple notification attribution. format: "int64". minimum: 1. |
| `google_package_name` | No | string | minLength: 1. maxLength: 512. |
| `initial_key_label` | No | string | maxLength: 512. default: "default". |

```json
{
  "type": "object",
  "required": [
    "name"
  ],
  "anyOf": [
    {
      "required": [
        "apple_bundle_id"
      ]
    },
    {
      "required": [
        "google_package_name"
      ]
    }
  ],
  "properties": {
    "name": {
      "type": "string",
      "minLength": 1,
      "maxLength": 512
    },
    "apple_bundle_id": {
      "type": "string",
      "minLength": 1,
      "maxLength": 512
    },
    "apple_app_apple_id": {
      "type": "integer",
      "format": "int64",
      "minimum": 1,
      "description": "Required for production Apple notification attribution."
    },
    "google_package_name": {
      "type": "string",
      "minLength": 1,
      "maxLength": 512
    },
    "initial_key_label": {
      "type": "string",
      "maxLength": 512,
      "default": "default"
    }
  },
  "additionalProperties": false
}
```

<a id="api-reference--updateapp"></a>

### UpdateApp



| Field | Required | Type | Details |
| --- | --- | --- | --- |
| `name` | No | string | minLength: 1. maxLength: 512. |
| `apple_bundle_id` | No | string or null | minLength: 1. maxLength: 512. |
| `apple_app_apple_id` | No | integer or null | format: "int64". minimum: 1. |
| `google_package_name` | No | string or null | minLength: 1. maxLength: 512. |
| `enabled` | No | boolean |  |

```json
{
  "type": "object",
  "minProperties": 1,
  "properties": {
    "name": {
      "type": "string",
      "minLength": 1,
      "maxLength": 512
    },
    "apple_bundle_id": {
      "type": [
        "string",
        "null"
      ],
      "minLength": 1,
      "maxLength": 512
    },
    "apple_app_apple_id": {
      "type": [
        "integer",
        "null"
      ],
      "format": "int64",
      "minimum": 1
    },
    "google_package_name": {
      "type": [
        "string",
        "null"
      ],
      "minLength": 1,
      "maxLength": 512
    },
    "enabled": {
      "type": "boolean"
    }
  },
  "additionalProperties": false
}
```

<a id="api-reference--apikey"></a>

### APIKey



| Field | Required | Type | Details |
| --- | --- | --- | --- |
| `id` | Yes | string | format: "uuid". |
| `kind` | Yes | string | Values: secret, publishable. |
| `label` | Yes | string | maxLength: 512. |
| `hint` | Yes | string | maxLength: 80. |
| `created_at` | Yes | string | format: "date-time". |
| `revoked_at` | No | string | format: "date-time". |

```json
{
  "type": "object",
  "required": [
    "id",
    "kind",
    "label",
    "hint",
    "created_at"
  ],
  "properties": {
    "id": {
      "type": "string",
      "format": "uuid"
    },
    "kind": {
      "type": "string",
      "enum": [
        "secret",
        "publishable"
      ]
    },
    "label": {
      "type": "string",
      "maxLength": 512
    },
    "hint": {
      "type": "string",
      "maxLength": 80
    },
    "created_at": {
      "type": "string",
      "format": "date-time"
    },
    "revoked_at": {
      "type": "string",
      "format": "date-time"
    }
  },
  "additionalProperties": false
}
```

<a id="api-reference--createdapikey"></a>

### CreatedAPIKey



| Field | Required | Type | Details |
| --- | --- | --- | --- |
| `api_key` | Yes | [APIKey](#api-reference--apikey) |  |
| `secret` | Yes | string | pattern: "^iap_(sk\|pk)_". writeOnly: true. |

```json
{
  "type": "object",
  "required": [
    "api_key",
    "secret"
  ],
  "properties": {
    "api_key": {
      "$ref": "#/components/schemas/APIKey"
    },
    "secret": {
      "type": "string",
      "pattern": "^iap_(sk|pk)_",
      "writeOnly": true
    }
  },
  "additionalProperties": false
}
```

<a id="api-reference--createdapp"></a>

### CreatedApp



| Field | Required | Type | Details |
| --- | --- | --- | --- |
| `app` | Yes | [App](#api-reference--app) |  |
| `api_key` | Yes | [APIKey](#api-reference--apikey) |  |
| `secret` | Yes | string | pattern: "^iap_sk_". writeOnly: true. |

```json
{
  "type": "object",
  "required": [
    "app",
    "api_key",
    "secret"
  ],
  "properties": {
    "app": {
      "$ref": "#/components/schemas/App"
    },
    "api_key": {
      "$ref": "#/components/schemas/APIKey"
    },
    "secret": {
      "type": "string",
      "pattern": "^iap_sk_",
      "writeOnly": true
    }
  },
  "additionalProperties": false
}
```

<a id="api-reference--catalogaccesslevel"></a>

### CatalogAccessLevel



| Field | Required | Type | Details |
| --- | --- | --- | --- |
| `id` | Yes | string | format: "uuid". |
| `lookup_key` | Yes | string | pattern: "^[A-Za-z0-9][A-Za-z0-9._-]{0,254}$". |
| `display_name` | Yes | string | maxLength: 512. |
| `created_at` | Yes | string | format: "date-time". |
| `updated_at` | Yes | string | format: "date-time". |

```json
{
  "type": "object",
  "required": [
    "id",
    "lookup_key",
    "display_name",
    "created_at",
    "updated_at"
  ],
  "properties": {
    "id": {
      "type": "string",
      "format": "uuid"
    },
    "lookup_key": {
      "type": "string",
      "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,254}$"
    },
    "display_name": {
      "type": "string",
      "maxLength": 512
    },
    "created_at": {
      "type": "string",
      "format": "date-time"
    },
    "updated_at": {
      "type": "string",
      "format": "date-time"
    }
  },
  "additionalProperties": false
}
```

<a id="api-reference--createaccesslevel"></a>

### CreateAccessLevel



| Field | Required | Type | Details |
| --- | --- | --- | --- |
| `lookup_key` | Yes | string | pattern: "^[A-Za-z0-9][A-Za-z0-9._-]{0,254}$". |
| `display_name` | No | string | maxLength: 512. |

```json
{
  "type": "object",
  "required": [
    "lookup_key"
  ],
  "properties": {
    "lookup_key": {
      "type": "string",
      "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,254}$"
    },
    "display_name": {
      "type": "string",
      "maxLength": 512
    }
  },
  "additionalProperties": false
}
```

<a id="api-reference--grantmode"></a>

### GrantMode




```json
{
  "type": "string",
  "enum": [
    "store_managed",
    "lifetime"
  ]
}
```

<a id="api-reference--productaccessmapping"></a>

### ProductAccessMapping



| Field | Required | Type | Details |
| --- | --- | --- | --- |
| `id` | Yes | string | format: "uuid". |
| `platform` | Yes | string | Values: apple, google. |
| `product_id` | Yes | string | minLength: 1. maxLength: 512. |
| `access_level_id` | Yes | string | format: "uuid". |
| `access_level_key` | Yes | string |  |
| `grant_mode` | Yes | [GrantMode](#api-reference--grantmode) |  |
| `starts_after_event_id` | Yes | integer | Mapping applies to source events with a greater ledger id. format: "int64". minimum: 0. |
| `created_at` | Yes | string | format: "date-time". |

```json
{
  "type": "object",
  "required": [
    "id",
    "platform",
    "product_id",
    "access_level_id",
    "access_level_key",
    "grant_mode",
    "starts_after_event_id",
    "created_at"
  ],
  "properties": {
    "id": {
      "type": "string",
      "format": "uuid"
    },
    "platform": {
      "type": "string",
      "enum": [
        "apple",
        "google"
      ]
    },
    "product_id": {
      "type": "string",
      "minLength": 1,
      "maxLength": 512
    },
    "access_level_id": {
      "type": "string",
      "format": "uuid"
    },
    "access_level_key": {
      "type": "string"
    },
    "grant_mode": {
      "$ref": "#/components/schemas/GrantMode"
    },
    "starts_after_event_id": {
      "type": "integer",
      "format": "int64",
      "minimum": 0,
      "description": "Mapping applies to source events with a greater ledger id."
    },
    "created_at": {
      "type": "string",
      "format": "date-time"
    }
  },
  "additionalProperties": false
}
```

<a id="api-reference--createproductaccessmapping"></a>

### CreateProductAccessMapping



| Field | Required | Type | Details |
| --- | --- | --- | --- |
| `platform` | Yes | string | Values: apple, google. |
| `product_id` | Yes | string | minLength: 1. maxLength: 512. |
| `access_level_id` | Yes | string | format: "uuid". |
| `grant_mode` | No | [GrantMode](#api-reference--grantmode) |  |

```json
{
  "type": "object",
  "required": [
    "platform",
    "product_id",
    "access_level_id"
  ],
  "properties": {
    "platform": {
      "type": "string",
      "enum": [
        "apple",
        "google"
      ]
    },
    "product_id": {
      "type": "string",
      "minLength": 1,
      "maxLength": 512
    },
    "access_level_id": {
      "type": "string",
      "format": "uuid"
    },
    "grant_mode": {
      "$ref": "#/components/schemas/GrantMode"
    }
  },
  "additionalProperties": false
}
```

<a id="api-reference--createclienttoken"></a>

### CreateClientToken



| Field | Required | Type | Details |
| --- | --- | --- | --- |
| `app_user_id` | Yes | string | minLength: 1. maxLength: 512. |
| `ttl_seconds` | No | integer | format: "int64". minimum: 300. maximum: 86400. default: 3600. |

```json
{
  "type": "object",
  "required": [
    "app_user_id"
  ],
  "properties": {
    "app_user_id": {
      "type": "string",
      "minLength": 1,
      "maxLength": 512
    },
    "ttl_seconds": {
      "type": "integer",
      "format": "int64",
      "minimum": 300,
      "maximum": 86400,
      "default": 3600
    }
  },
  "additionalProperties": false
}
```

<a id="api-reference--createdclienttoken"></a>

### CreatedClientToken



| Field | Required | Type | Details |
| --- | --- | --- | --- |
| `id` | Yes | string | format: "uuid". |
| `app_user_id` | Yes | string | minLength: 1. maxLength: 512. |
| `hint` | Yes | string | maxLength: 80. |
| `expires_at` | Yes | string | format: "date-time". |
| `token` | Yes | string | pattern: "^iap_ct_". writeOnly: true. |

```json
{
  "type": "object",
  "required": [
    "id",
    "app_user_id",
    "hint",
    "expires_at",
    "token"
  ],
  "properties": {
    "id": {
      "type": "string",
      "format": "uuid"
    },
    "app_user_id": {
      "type": "string",
      "minLength": 1,
      "maxLength": 512
    },
    "hint": {
      "type": "string",
      "maxLength": 80
    },
    "expires_at": {
      "type": "string",
      "format": "date-time"
    },
    "token": {
      "type": "string",
      "pattern": "^iap_ct_",
      "writeOnly": true
    }
  },
  "additionalProperties": false
}
```

<a id="api-reference--error"></a>

### Error



| Field | Required | Type | Details |
| --- | --- | --- | --- |
| `error` | Yes | string |  |

```json
{
  "type": "object",
  "required": [
    "error"
  ],
  "properties": {
    "error": {
      "type": "string"
    }
  },
  "additionalProperties": false
}
```


<a id="api-reference--shared-error-responses"></a>

## Shared error responses

<a id="api-reference--invalidrequest"></a>

### InvalidRequest

Payload is malformed or fails store validation.

```json
{
  "description": "Payload is malformed or fails store validation.",
  "content": {
    "application/json": {
      "schema": {
        "$ref": "#/components/schemas/Error"
      }
    }
  }
}
```

<a id="api-reference--unauthorized"></a>

### Unauthorized

The required bearer credential is missing, invalid, or revoked.

```json
{
  "description": "The required bearer credential is missing, invalid, or revoked.",
  "headers": {
    "WWW-Authenticate": {
      "schema": {
        "type": "string"
      }
    }
  },
  "content": {
    "application/json": {
      "schema": {
        "$ref": "#/components/schemas/Error"
      }
    }
  }
}
```

<a id="api-reference--toolarge"></a>

### TooLarge

Request exceeds 256 KiB.

```json
{
  "description": "Request exceeds 256 KiB.",
  "content": {
    "application/json": {
      "schema": {
        "$ref": "#/components/schemas/Error"
      }
    }
  }
}
```

<a id="api-reference--ratelimited"></a>

### RateLimited

The authenticated API key exceeded its process-local receipt rate limit.

```json
{
  "description": "The authenticated API key exceeded its process-local receipt rate limit.",
  "headers": {
    "Retry-After": {
      "schema": {
        "type": "string"
      }
    }
  },
  "content": {
    "application/json": {
      "schema": {
        "$ref": "#/components/schemas/Error"
      }
    }
  }
}
```

<a id="api-reference--unavailable"></a>

### Unavailable

A verifier or durable dependency is temporarily unavailable.

```json
{
  "description": "A verifier or durable dependency is temporarily unavailable.",
  "headers": {
    "Retry-After": {
      "schema": {
        "type": "string"
      }
    }
  },
  "content": {
    "application/json": {
      "schema": {
        "$ref": "#/components/schemas/Error"
      }
    }
  }
}
```

<a id="api-reference--notfound"></a>

### NotFound

The app-scoped resource does not exist.

```json
{
  "description": "The app-scoped resource does not exist.",
  "content": {
    "application/json": {
      "schema": {
        "$ref": "#/components/schemas/Error"
      }
    }
  }
}
```

<a id="api-reference--conflict"></a>

### Conflict

The requested identifier or lifecycle transition conflicts with existing state.

```json
{
  "description": "The requested identifier or lifecycle transition conflicts with existing state.",
  "content": {
    "application/json": {
      "schema": {
        "$ref": "#/components/schemas/Error"
      }
    }
  }
}
```

<a id="api-reference--forbidden"></a>

### Forbidden

The authenticated key or customer token is not authorized for this resource.

```json
{
  "description": "The authenticated key or customer token is not authorized for this resource.",
  "content": {
    "application/json": {
      "schema": {
        "$ref": "#/components/schemas/Error"
      }
    }
  }
}
```



---

<a id="hosted-api"></a>

# Hosted account and dashboard API

The hosted API serves the dashboard and CherrySub CLI. It uses the opaque `JSESSIONID` cookie, verified account identity, selected project, app ownership, and role checks. It is separate from the publishable/secret-key runtime API and from root `/admin/v1` administration.

This chapter covers the reviewed request shapes and the complete route inventory. Its JSON field names are preserved exactly: hosted responses commonly use camelCase, many write bodies use snake_case, and saved-chart bodies use camelCase. Do not apply a global casing conversion.

<a id="hosted-api--authentication-and-session-context"></a>

## Authentication and session context

`POST /auth/login` accepts `email` and `password`, sets `JSESSIONID`, and returns email-verification state. `POST /auth/register` also takes `name`, `surname`, and an optional safe local `callbackUrl`. Registering/signing in is not equivalent to verified dashboard access.

Verification confirmation and password reset use their bounded-lifetime account tokens. The verification-request flow can include a local `callbackUrl`. Logout revokes the current session; logout-all revokes the account's sessions. Account/session management follows the [account guide](#accounts-and-projects).

For normal automation, use [CherrySub CLI](#cli) so it manages private session storage and project selection. If you integrate the HTTP interface directly, keep cookies private, refuse credential-bearing redirects, and serialize selected-project changes for a shared session.

<a id="hosted-api--authorization-rules"></a>

## Authorization rules

Dashboard operations require a verified session and membership in the selected project. Resource IDs are checked against project/app ownership, not trusted merely because a caller supplies a UUID.

App/catalog/key/store/webhook configuration and support-note writes require owner/admin authority. Team changes have additional owner/admin target restrictions. Invitation-management reads also require manager authority. Saved views/charts and exports bind to the requesting account and project membership rather than being shared configuration writes.

`POST /dashboard/projects/{project_id}/members` intentionally rejects direct member addition. Use invitation creation and acceptance instead. A viewer cannot bypass a disabled dashboard button by calling the write route directly.

<a id="hosted-api--project-and-app-setup-example"></a>

## Project and app setup example

The project-creation body is:

```json
{ "name": "My project" }
```

After creation or `GET /dashboard/projects`, select the intended project with `POST /dashboard/projects/{project_id}/select`. It changes session context and returns no content.

To create an app in that hosted project, use `POST /dashboard/apps`:

```json
{
  "name": "My app",
  "apple_bundle_id": "com.example.app",
  "apple_app_apple_id": 1234567890,
  "google_package_name": "com.example.app"
}
```

Use only the identities your app supports. The hosted response contains `app`, `secretKey`, and `publishableKey`; the keys are one-time plaintext. This differs from the root-admin app response. Store and manage those values through the correct server/client boundaries.

<a id="hosted-api--managed-store-credential-bodies"></a>

## Managed store credential bodies

`PUT /dashboard/apps/{app_id}/store-connections/apple` accepts `issuer_id`, `key_id`, and `private_key_pem`. Google accepts `service_account_json` as an embedded JSON object, not a string containing escaped JSON. The platform must be `apple` or `google`.

The list/PUT response exposes safe metadata: platform, source, account hint, and timestamps. DELETE disconnects the app's server credential use. `managed` and `environment` are credential sources; neither is proof that a live purchase passed.

The hosted JSON body limit is 32 KiB, except the two catalog-import endpoints, which allow a 1 MiB JSON envelope containing a source file up to 512 KiB. The Google credential validator's larger 64 KiB allowance is therefore not the effective maximum HTTP upload size; escaping/envelope overhead also counts. [Store setup](#google-play) · [Limits](#limits-and-release-status).

<a id="hosted-api--dataset-and-chart-queries"></a>

## Dataset and chart queries

Customer, transaction, and event list requests support their resource-specific search/filter/sort fields through the hosted data-input parser. Common fields include `query`, `platform`, `environment`, `status`, `type`, `page`, and `page_size`; time/product/store filters depend on the resource. Default page size is 50, maximum 100. Unsupported filters or invalid pagination are rejected rather than applied to an unrelated dataset.

`GET /dashboard/charts` requires an `app_id` and a valid metric/range configuration:

| Query field | Purpose |
| --- | --- |
| `app_id` | App UUID belonging to the selected project |
| `metric` | One metric ID from the [metric dictionary](#metrics) |
| `days` | Supported relative range when custom dates are absent |
| `from`, `to` | Inclusive custom UTC dates |
| `resolution` | day, week, or month within range limits |
| `offset_days` | Supported comparison offset |
| `platform`, `environment`, `product_id` | Applicable data filters |
| `retention_period` | Weekly/monthly/yearly duration selection for retention |
| `conversion_timeframe` | Customer-conversion observation window |
| `customer_lifetime` | Realized LTV window |
| `cancellation_window` | Trial opt-out window |
| `refund_request_reason` | Refund review reason filter |
| `cohort_definition`, `cohort_measure` | Cohort matrix anchor/measure |
| `currency` | Monetary cohort selection where applicable |

Do not send unrelated metric-specific settings as if every combination were supported. Charts are bounded interactive aggregates; they are not a full export API. Product detail requests may need `app_id` to disambiguate a product reused by multiple apps.

<a id="hosted-api--export-and-saved-analysis-examples"></a>

## Export and saved-analysis examples

Create an export with `POST /dashboard/exports`:

```json
{
  "app_id": "11111111-1111-4111-8111-111111111111",
  "resource": "transactions",
  "from_date": "2026-08-01",
  "to_date": "2026-08-31",
  "platform": "apple",
  "environment": "sandbox",
  "product_id": "pro.monthly"
}
```

The service returns 202 and a job record. List your jobs with `GET /dashboard/exports?app_id=...`. The Location header identifies the job; the current router does not expose a general GET-by-job-ID endpoint there. Use the list/status and emailed download flow.

Saved views take `name`, `resource` (customers, transactions, or events), and a JSON `filters` object. Saved charts take the complete camelCase chart configuration shown in their body contract below. Both are requester-scoped; the API does not make another person's saved analysis writable merely because you know its ID.

<a id="hosted-api--errors-and-http-behavior"></a>

## Errors and HTTP behavior

Hosted errors use `message`; account-link failures may also have `code`, such as `invalid_or_expired_token`, `email_verification_required`, or `invitation_email_mismatch`. Common statuses are 400 invalid input, 401 invalid session/credentials, 403 verification/role failure, 404 scoped resource missing, 409 lifecycle conflict, 429 throttling, and 503 delivery/dependency unavailable.

The generic invalid-input message can say “Invalid account details” even for a dashboard write. Interpret it with the operation's body contract, not as proof that your account email is wrong.

Send `Content-Type: application/json` for JSON bodies. Strict decoding rejects unsupported fields/trailing JSON. The cookie API uses its own auth/account throttles; runtime receipt-key throttling is a different boundary. The control plane has a process-local `/healthz`; the public staging proxy's health path can route to the core instead.

<a id="hosted-api--catalog-import-and-store-product-reads"></a>

## Catalog import and store product reads

Verified owner/admin accounts can call the app-scoped catalog routes. `POST /dashboard/apps/{app_id}/catalog-import/preview` accepts `format` (`csv` or `json`), string `content`, and optional `activate_current`. It returns a read-only plan with normalized catalog, counts, changes, issues, notes, `canApply`, and `digest`. Apply sends the same fields to `/catalog-import/apply` plus `expected_digest` from the preview. A changed source, scope, or target returns HTTP 409; refresh the preview before retrying. Application is atomic and repeated successful requests return their saved result. `GET /dashboard/apps/{app_id}/catalog-imports` returns recent applied summaries.

`POST /dashboard/apps/{app_id}/store-products/{platform}` accepts an optional opaque `cursor` and returns `products`, optional `nextCursor`, and `notes`. Read every page before applying a selected catalog. Platforms are `apple` and `google`. These requests do not create products in the store or write the destination catalog.

The separate Apple catalog connection lives at `/dashboard/apps/{app_id}/catalog-connection/apple`: GET returns safe metadata, PUT takes `issuer_id`, `key_id`, and `private_key_pem`, and DELETE removes it. The .p8 is an App Store Connect API key, not the IAP key. Both store listing and credential routes require manager authority and destination project/app ownership. Secret material is encrypted and never returned. Google catalog listing uses its existing managed service account. [Migration flow](#migration).

<a id="hosted-api--complete-route-index"></a>

## Complete route index

This index is extracted from the hosted router at the reviewed backend revision. Body contracts are listed in the following section. Read the authorization notes above: not every write is a manager operation, and not every read exposes manager-only metadata to a viewer.

| Method | Route | Body contract |
| --- | --- | --- |
| GET | `/healthz` | See operation notes / no named JSON body |
| POST | `/auth/register` | [registerRequest](#hosted-api--registerrequest) |
| POST | `/auth/login` | [loginRequest](#hosted-api--loginrequest) |
| POST | `/auth/logout` | See operation notes / no named JSON body |
| POST | `/auth/logout-all` | See operation notes / no named JSON body |
| GET | `/auth/account` | See operation notes / no named JSON body |
| POST | `/auth/verify-email/request` | See operation notes / no named JSON body |
| POST | `/auth/verify-email/confirm` | [accountTokenRequest](#hosted-api--accounttokenrequest) |
| POST | `/auth/password-reset/request` | [passwordResetRequest](#hosted-api--passwordresetrequest) |
| POST | `/auth/password-reset/confirm` | [resetPasswordRequest](#hosted-api--resetpasswordrequest) |
| POST | `/auth/password/change` | [changePasswordRequest](#hosted-api--changepasswordrequest) |
| GET | `/auth/sessions` | See operation notes / no named JSON body |
| DELETE | `/auth/sessions/{session_id}` | See operation notes / no named JSON body |
| GET | `/auth/project-invitations/preview` | See operation notes / no named JSON body |
| POST | `/auth/project-invitations/accept` | [accountTokenRequest](#hosted-api--accounttokenrequest) |
| GET | `/dashboard` | See operation notes / no named JSON body |
| GET | `/dashboard/charts` | See operation notes / no named JSON body |
| GET | `/dashboard/exports` | See operation notes / no named JSON body |
| POST | `/dashboard/exports` | [exportJobRequest](#hosted-api--exportjobrequest) |
| GET | `/dashboard/projects` | See operation notes / no named JSON body |
| POST | `/dashboard/projects` | [projectRequest](#hosted-api--projectrequest) |
| PATCH | `/dashboard/projects/{project_id}` | [projectRequest](#hosted-api--projectrequest) |
| POST | `/dashboard/projects/{project_id}/select` | See operation notes / no named JSON body |
| GET | `/dashboard/projects/{project_id}/members` | See operation notes / no named JSON body |
| POST | `/dashboard/projects/{project_id}/members` | See operation notes / no named JSON body |
| PATCH | `/dashboard/projects/{project_id}/members/{user_id}` | [projectMemberRequest](#hosted-api--projectmemberrequest) |
| DELETE | `/dashboard/projects/{project_id}/members/{user_id}` | See operation notes / no named JSON body |
| GET | `/dashboard/projects/{project_id}/invitations` | See operation notes / no named JSON body |
| POST | `/dashboard/projects/{project_id}/invitations` | [projectMemberRequest](#hosted-api--projectmemberrequest) |
| DELETE | `/dashboard/projects/{project_id}/invitations/{invitation_id}` | See operation notes / no named JSON body |
| GET | `/dashboard/projects/{project_id}/activity` | See operation notes / no named JSON body |
| GET | `/dashboard/customers` | See operation notes / no named JSON body |
| GET | `/dashboard/customers/{customer_id}` | See operation notes / no named JSON body |
| POST | `/dashboard/customers/{customer_id}/notes` | [customerSupportNoteRequest](#hosted-api--customersupportnoterequest) |
| PATCH | `/dashboard/customers/{customer_id}/notes/{note_id}` | [customerSupportNoteRequest](#hosted-api--customersupportnoterequest) |
| DELETE | `/dashboard/customers/{customer_id}/notes/{note_id}` | See operation notes / no named JSON body |
| GET | `/dashboard/events` | See operation notes / no named JSON body |
| GET | `/dashboard/transactions` | See operation notes / no named JSON body |
| GET | `/dashboard/transactions/{event_id}` | See operation notes / no named JSON body |
| GET | `/dashboard/products/{platform}/{product_id}` | See operation notes / no named JSON body |
| GET | `/dashboard/access-levels/{access_level_id}` | See operation notes / no named JSON body |
| POST | `/dashboard/apps/{app_id}/access-levels` | [accessLevelRequest](#hosted-api--accesslevelrequest) |
| GET | `/dashboard/apps/{app_id}/product-access-mappings` | See operation notes / no named JSON body |
| POST | `/dashboard/apps/{app_id}/product-access-mappings` | [productAccessMappingRequest](#hosted-api--productaccessmappingrequest) |
| DELETE | `/dashboard/apps/{app_id}/product-access-mappings/{mapping_id}` | See operation notes / no named JSON body |
| GET | `/dashboard/integrations/{kind}` | See operation notes / no named JSON body |
| GET | `/dashboard/apps` | See operation notes / no named JSON body |
| POST | `/dashboard/apps` | [createAppRequest](#hosted-api--createapprequest) |
| GET | `/dashboard/apps/artwork` | See operation notes / no named JSON body |
| GET | `/dashboard/apps/{app_id}` | See operation notes / no named JSON body |
| PATCH | `/dashboard/apps/{app_id}` | [createAppRequest](#hosted-api--createapprequest) |
| GET | `/dashboard/apps/{app_id}/store-connections` | See operation notes / no named JSON body |
| PUT | `/dashboard/apps/{app_id}/store-connections/{platform}` | [appleConnectionRequest](#hosted-api--appleconnectionrequest), [googleConnectionRequest](#hosted-api--googleconnectionrequest) |
| DELETE | `/dashboard/apps/{app_id}/store-connections/{platform}` | See operation notes / no named JSON body |
| GET | `/dashboard/apps/{app_id}/api-keys` | See operation notes / no named JSON body |
| POST | `/dashboard/apps/{app_id}/api-keys` | [apiKeyRequest](#hosted-api--apikeyrequest) |
| DELETE | `/dashboard/apps/{app_id}/api-keys/{key_id}` | See operation notes / no named JSON body |
| GET | `/dashboard/apps/{app_id}/product-sets` | See operation notes / no named JSON body |
| POST | `/dashboard/apps/{app_id}/product-sets` | [productSetRequest](#hosted-api--productsetrequest) |
| PATCH | `/dashboard/apps/{app_id}/product-sets/{product_set_id}` | [updateProductSetRequest](#hosted-api--updateproductsetrequest) |
| DELETE | `/dashboard/apps/{app_id}/product-sets/{product_set_id}` | See operation notes / no named JSON body |
| POST | `/dashboard/apps/{app_id}/product-sets/{product_set_id}/packages` | [productSetPackageRequest](#hosted-api--productsetpackagerequest) |
| DELETE | `/dashboard/apps/{app_id}/product-sets/{product_set_id}/packages/{package_id}` | See operation notes / no named JSON body |
| GET | `/dashboard/apps/{app_id}/webhooks` | See operation notes / no named JSON body |
| POST | `/dashboard/apps/{app_id}/webhooks` | [webhookEndpointRequest](#hosted-api--webhookendpointrequest) |
| PATCH | `/dashboard/apps/{app_id}/webhooks/{endpoint_id}` | [updateWebhookEndpointRequest](#hosted-api--updatewebhookendpointrequest) |
| DELETE | `/dashboard/apps/{app_id}/webhooks/{endpoint_id}` | See operation notes / no named JSON body |
| POST | `/dashboard/apps/{app_id}/webhooks/{endpoint_id}/rotate-secret` | See operation notes / no named JSON body |
| GET | `/dashboard/apps/{app_id}/webhook-deliveries` | See operation notes / no named JSON body |
| GET | `/dashboard/apps/{app_id}/webhook-deliveries/{message_id}` | See operation notes / no named JSON body |
| POST | `/dashboard/apps/{app_id}/webhook-deliveries/{message_id}/retry` | See operation notes / no named JSON body |
| GET | `/dashboard/saved-views` | See operation notes / no named JSON body |
| POST | `/dashboard/saved-views` | [createSavedViewRequest](#hosted-api--createsavedviewrequest) |
| PATCH | `/dashboard/saved-views/{view_id}` | [createSavedViewRequest](#hosted-api--createsavedviewrequest) |
| DELETE | `/dashboard/saved-views/{view_id}` | See operation notes / no named JSON body |
| GET | `/dashboard/saved-views/{view_id}/results` | See operation notes / no named JSON body |
| GET | `/dashboard/saved-charts` | See operation notes / no named JSON body |
| POST | `/dashboard/saved-charts` | [savedChartRequest](#hosted-api--savedchartrequest) |
| PATCH | `/dashboard/saved-charts/{chart_id}` | [savedChartRequest](#hosted-api--savedchartrequest) |
| DELETE | `/dashboard/saved-charts/{chart_id}` | See operation notes / no named JSON body |
| POST | `/dashboard/apps/{app_id}/store-products/{platform}` | See operation notes / no named JSON body |
| GET | `/dashboard/apps/{app_id}/catalog-connection/apple` | See operation notes / no named JSON body |
| PUT | `/dashboard/apps/{app_id}/catalog-connection/apple` | [appleConnectionRequest](#hosted-api--appleconnectionrequest) |
| DELETE | `/dashboard/apps/{app_id}/catalog-connection/apple` | See operation notes / no named JSON body |
| POST | `/dashboard/apps/{app_id}/catalog-import/preview` | [CatalogImportInput](#hosted-api--catalogimportinput) |
| POST | `/dashboard/apps/{app_id}/catalog-import/apply` | [CatalogImportInput](#hosted-api--catalogimportinput) |
| GET | `/dashboard/apps/{app_id}/catalog-imports` | See operation notes / no named JSON body |

<a id="hosted-api--hosted-request-body-fields"></a>

## Hosted request body fields

These field names and transport types are extracted from the handler DTOs. Required/allowed combinations and lifecycle permissions are validated by the service; a zero-value Go field is not a declaration that the field is optional. This table deliberately does not invent a formal OpenAPI contract for the hosted interface. `JSON value` means an embedded object/value, not a stringified JSON blob.

<a id="hosted-api--registerrequest"></a>

### registerRequest

| JSON field | Transport type |
| --- | --- |
| `callbackUrl` | string |
| `name` | string |
| `surname` | string |
| `email` | string |
| `password` | string |

<a id="hosted-api--loginrequest"></a>

### loginRequest

| JSON field | Transport type |
| --- | --- |
| `email` | string |
| `password` | string |

<a id="hosted-api--accounttokenrequest"></a>

### accountTokenRequest

| JSON field | Transport type |
| --- | --- |
| `token` | string |

<a id="hosted-api--passwordresetrequest"></a>

### passwordResetRequest

| JSON field | Transport type |
| --- | --- |
| `email` | string |

<a id="hosted-api--resetpasswordrequest"></a>

### resetPasswordRequest

| JSON field | Transport type |
| --- | --- |
| `token` | string |
| `newPassword` | string |

<a id="hosted-api--changepasswordrequest"></a>

### changePasswordRequest

| JSON field | Transport type |
| --- | --- |
| `currentPassword` | string |
| `newPassword` | string |

<a id="hosted-api--exportjobrequest"></a>

### exportJobRequest

| JSON field | Transport type |
| --- | --- |
| `app_id` | UUID string |
| `resource` | string |
| `from_date` | string |
| `to_date` | string |
| `platform` | string |
| `environment` | string |
| `product_id` | string |

<a id="hosted-api--customersupportnoterequest"></a>

### customerSupportNoteRequest

| JSON field | Transport type |
| --- | --- |
| `body` | string |

<a id="hosted-api--accesslevelrequest"></a>

### accessLevelRequest

| JSON field | Transport type |
| --- | --- |
| `lookup_key` | string |
| `display_name` | string |

<a id="hosted-api--productaccessmappingrequest"></a>

### productAccessMappingRequest

| JSON field | Transport type |
| --- | --- |
| `platform` | string |
| `product_id` | string |
| `access_level_id` | UUID string |
| `grant_mode` | string |

<a id="hosted-api--projectrequest"></a>

### projectRequest

| JSON field | Transport type |
| --- | --- |
| `name` | string |

<a id="hosted-api--projectmemberrequest"></a>

### projectMemberRequest

| JSON field | Transport type |
| --- | --- |
| `email` | string |
| `role` | string |

<a id="hosted-api--createapprequest"></a>

### createAppRequest

| JSON field | Transport type |
| --- | --- |
| `name` | string |
| `apple_bundle_id` | string; pointer field supports omission |
| `apple_app_apple_id` | integer (int64); pointer field supports omission |
| `google_package_name` | string; pointer field supports omission |

<a id="hosted-api--appleconnectionrequest"></a>

### appleConnectionRequest

| JSON field | Transport type |
| --- | --- |
| `issuer_id` | string |
| `key_id` | string |
| `private_key_pem` | string |

<a id="hosted-api--googleconnectionrequest"></a>

### googleConnectionRequest

| JSON field | Transport type |
| --- | --- |
| `service_account_json` | JSON value |

<a id="hosted-api--apikeyrequest"></a>

### apiKeyRequest

| JSON field | Transport type |
| --- | --- |
| `kind` | string |
| `label` | string |

<a id="hosted-api--productsetrequest"></a>

### productSetRequest

| JSON field | Transport type |
| --- | --- |
| `identifier` | string |
| `display_name` | string |
| `is_current` | boolean |

<a id="hosted-api--updateproductsetrequest"></a>

### updateProductSetRequest

| JSON field | Transport type |
| --- | --- |
| `is_current` | boolean |

<a id="hosted-api--productsetpackagerequest"></a>

### productSetPackageRequest

| JSON field | Transport type |
| --- | --- |
| `identifier` | string |
| `package_type` | string |
| `platform` | string |
| `product_id` | string |

<a id="hosted-api--webhookendpointrequest"></a>

### webhookEndpointRequest

| JSON field | Transport type |
| --- | --- |
| `url` | string |
| `description` | string |
| `event_types` | array of strings |

<a id="hosted-api--updatewebhookendpointrequest"></a>

### updateWebhookEndpointRequest

| JSON field | Transport type |
| --- | --- |
| `url` | string; pointer field supports omission |
| `description` | string; pointer field supports omission |
| `event_types` | array of strings; pointer field supports omission |
| `enabled` | boolean; pointer field supports omission |

<a id="hosted-api--createsavedviewrequest"></a>

### createSavedViewRequest

| JSON field | Transport type |
| --- | --- |
| `name` | string |
| `resource` | string |
| `filters` | JSON value |

<a id="hosted-api--savedchartrequest"></a>

### savedChartRequest

| JSON field | Transport type |
| --- | --- |
| `appId` | UUID string |
| `name` | string |
| `metric` | string |
| `days` | integer |
| `fromDate` | string |
| `toDate` | string |
| `resolution` | string |
| `compareMode` | string |
| `platform` | string |
| `environment` | string |
| `productId` | string |
| `chartType` | string |
| `segment` | string |
| `currency` | string |
| `retentionPeriod` | string |
| `retentionMode` | string |
| `conversionTimeframe` | string |
| `customerLifetime` | string |
| `cancellationWindow` | string |
| `refundRequestReason` | string |
| `cohortDefinition` | string |
| `cohortMeasure` | string |

<a id="hosted-api--catalogimportinput"></a>

### CatalogImportInput

| JSON field | Transport type |
| --- | --- |
| `format` | string |
| `content` | string |
| `activate_current` | bool |
| `expected_digest` | string |



---

<a id="testing"></a>

# Sandbox testing and release readiness

Test the full path from storefront evidence to protected application behavior. The dashboard's setup checkmarks and a successful credential upload are useful checkpoints, but neither proves the complete integration.

<a id="testing--establish-a-controlled-test-environment"></a>

## Establish a controlled test environment

Use a verified test account, a dedicated staging app/project, the staging API URL, staging app keys, and store-backed sandbox/test purchases. Verify bundle/package/app IDs and environment filters before creating evidence.

Use supported store testers and storefront builds. A local Xcode StoreKit fixture or a mocked Google purchase object is useful for client UI tests but is not a substitute for server-verifiable store evidence. The core has no runtime store-verification bypass.

Prepare a test product, access level, mapping, and current product set. Your backend must authenticate a real application user and mint a customer token for that user. Use a public HTTPS test receiver if verifying outbound webhooks.

<a id="testing--end-to-end-acceptance-matrix"></a>

## End-to-end acceptance matrix

| Scenario | Expected evidence | Expected application behavior |
| --- | --- | --- |
| First paid subscription | Verified transaction, customer binding, mapped active access | Feature unlocks after verification |
| Free trial | Trial state and expiry; no fabricated paid revenue | Access follows configured store grant |
| Renewal | Updated/next authoritative period and corresponding history | Access continues without a duplicate account |
| Auto-renewal cancelled | Cancellation intent but remaining valid paid period | Access remains until verified expiry |
| Expiry | Subscriber read evaluates expiry as inactive | Feature locks if no other active grant remains |
| Full refund/revocation | Store refund evidence and affected grant removal | Other valid grants still contribute |
| One-time product | Correct explicit product type and completion policy | Permanent/consumable behavior matches your product policy |
| Pending/cancelled purchase | No false paid receipt acceptance/completion | UI stays pending/cancelled, without premium fallback |
| Duplicate submission | Deduplicated receipt success, no duplicate sale | Completion can recover safely |
| Timeout after possible commit | Retry the same evidence | No second fabricated purchase |
| Restore to intended account | Verified purchase ownership behavior | Correct target user's access; no implicit full identity merge |
| Account switch | New token subject and cleared old UI cache | No previous user's access leaks into the new session |
| Expired customer token | One forced refresh, then success or clear failure | No server-secret fallback in the app |
| Bad app/key/subject | Authentication or validation rejection before store effects | Clear recoverable error |
| Receiver returns 5xx | Ordered retry record | Durable receiver dedupe prevents repeated side effects |
| Receiver returns 410 | Dead letter and disabled endpoint | Operator fixes/re-enables intentionally |
| Secret rotation | Overlap signatures accepted | Receiver remains available during rollout |

Test both stores independently. Google one-time purchases and subscriptions must use different API product types. For a cross-store access union, expire/refund one grant while keeping another active and verify the final access level remains correct.

<a id="testing--trace-one-purchase-all-the-way-through"></a>

## Trace one purchase all the way through

Record the application user ID, store transaction/token reference in a safe diagnostic context, product ID, app ID, environment, and approximate UTC timestamps. Locate the transaction, its customer, the product mapping, and related event evidence in CherrySub. Confirm the SDK's resulting subscriber map matches the feature you actually unlock.

Do not include raw credentials or full purchase tokens in public bug reports. Use non-secret transaction IDs and redacted logs where sufficient.

<a id="testing--test-reporting-and-exports"></a>

## Test reporting and exports

Use known purchases in a single currency, refunds with known timestamps, and trial/paid cohorts that have both mature and incomplete periods. Compare the chart's stated attribution rules to [metric definitions](#metrics). Verify custom ranges, previous-period comparisons, empty results, and missing accounting facts.

Save a chart/view, reopen it, and confirm the app and filters. Export aggregates separately from raw normalized history. Check requester privacy, email delivery, link expiry, and oversized-job failure.

<a id="testing--developer-verification-commands"></a>

## Developer verification commands

From a source checkout, core checks include `go test -race ./...`, `go vet ./...`, formatting, static analysis, and vulnerability scanning. PostgreSQL integration tests require an explicit disposable `TEST_DATABASE_URL`; do not point them at a live database.

SDK checks are available through `make sdk-test` or the per-language targets. The web repository uses `npm run build` and Playwright tests. The CLI can validate a setup plan with `--dry-run`; real account provisioning must be tested separately by an authorized store operator.

<a id="testing--before-a-production-rollout"></a>

## Before a production rollout

Confirm a production deployment has its own secrets, database/recovery design, store endpoints, monitoring, and tested rollback. The current public preview is staging; the recorded production topology is a plan, not a live service guarantee. Registry distribution, Google OAuth setup, notification wiring, and known reporting boundaries are described in [release status](#limits-and-release-status).


---

<a id="troubleshooting"></a>

# Troubleshooting

Start with the exact failing boundary: account/session, app identity, store credential, receipt verification, access mapping, presentation, delivery, or reporting. Repeating every setup step at once makes the cause harder to isolate.

<a id="troubleshooting--account-invitation-and-permission-failures"></a>

## Account, invitation, and permission failures

| Symptom | Check | Next action |
| --- | --- | --- |
| Sign-in fails | Correct account and deployment; password rules do not change an existing password | Use the intended account or password reset |
| Verification/reset link fails | Latest link, expiry, correct browser/account | Request a new link and continue locally |
| Invitation email mismatch | The signed-in email versus the invited email | Use the invited account |
| View access / 403 | Project role and email verification | Ask an owner/admin for the needed role |
| Shared analysis cannot open | Recipient's project/app membership | Grant legitimate membership; a URL alone grants none |
| Resource not found after switching | Current project/app versus the old link | Reselect scope and reopen the resource |

Default link lifetimes are 24 hours for verification, 30 minutes for reset, and seven days for invitations. Missing email can also indicate gateway configuration or delivery failure. [Accounts](#accounts-and-projects).

<a id="troubleshooting--a-store-credential-will-not-save"></a>

## A store credential will not save

For Apple, use the In-App Purchase `.p8`, its correct Key ID, and Issuer ID. The filename can populate Key ID but cannot prove the key is the right kind or belongs to the intended team. In CLI setup, check the selected provider and the existence of the bundle record.

For Google, choose the service-account key JSON, not a Desktop OAuth client JSON. Check its token URI and account email, the package-level Play grants, and API enablement. Organization policies can legitimately prevent key creation.

Managed credential storage requires the installation's encryption-key configuration. A 409 can indicate unavailable managed storage rather than a bad store password. The credential validator's file limits and the hosted API envelope limit are different; see [Limits](#limits-and-release-status) for large uploads.

<a id="troubleshooting--apple-purchase-fails-before-reaching-cherrysub"></a>

## Apple purchase fails before reaching CherrySub

If StoreKit itself returns an error (including an unexplained 3504), first determine whether it happened before any signed transaction was produced or any receipt request reached the API. Inspect tester/build eligibility, agreements, product availability, bundle/app IDs, network, and storefront logs. A pre-purchase store error is not repaired by fabricating a JWS or disabling backend verification.

For reproducible store-side failures, collect non-secret timestamps, product/bundle IDs, build information, and the store error domain/code for the appropriate Apple support path. Do not assume every 3504 error has the same cause.

<a id="troubleshooting--purchase-exists-but-access-is-inactive"></a>

## Purchase exists but access is inactive

1. Confirm the selected app and the broker's App User ID.
2. Find the verified transaction and its current owner.
3. Check the exact product/platform access mapping and its grant mode.
4. Compare expiry and refund/revocation evidence.
5. Inspect other grant contributions to the same access level.
6. Refresh the subscriber response and clear stale application-owned access UI.

A product set controls presentation, not access. A mapping edit does not retroactively rewrite all past evidence. Restore can change one purchase's ownership without merging identities. [Access](#access-levels) · [Ownership](#customer-identity).

<a id="troubleshooting--receiptapi-errors"></a>

## Receipt/API errors

| Status | Common meaning | Handling |
| --- | --- | --- |
| 400 / 422 | Bad request shape or store evidence/identity validation | Correct the payload or configuration; do not blindly retry unchanged invalid data |
| 401 | Missing, expired, revoked, cross-app, or invalid credential/token | Refresh once where supported; verify broker subject and deployment |
| 403 | Wrong key kind or insufficient hosted project permission | Use the documented trust boundary |
| 404 | Missing scoped resource or a not-yet-created customer alias | Confirm scope; do not treat a missing subscriber as active |
| 409 | Configuration or lifecycle conflict | Inspect current state before retrying |
| 413 | Request too large | Use the supported payload, not an entire unrelated receipt bundle |
| 429 | Throttled | Respect Retry-After and reduce concurrent retries |
| 503 | Verifier/dependency/configuration unavailable | Investigate readiness and retry transient failures with bounds |

Runtime API errors use an `error` field. Hosted account/dashboard errors use `message` and sometimes a machine-readable `code`. The [API references](#api-reference) and [hosted API](#hosted-api) distinguish them.

<a id="troubleshooting--notifications-or-webhooks-are-missing"></a>

## Notifications or webhooks are missing

Apple notifications require the V2 URL and matching signed app identity. Google RTDN requires an authenticated Pub/Sub push audience/email and a successful authoritative purchase read for purchase notifications. A test notification alone does not prove paid purchase access.

For outbound webhooks, confirm the endpoint is enabled, its filter matches a supported derived event, and the receiver returns 2xx. Inspect the head-of-line failed delivery. Verify signatures against raw bytes and the current/overlap secrets. Fix the receiver before replay; replay preserves its message ID.

<a id="troubleshooting--charts-disagree-with-another-report"></a>

## Charts disagree with another report

Check app, environment, currency, UTC range, resolution, current-snapshot versus period series, and cohort anchor/window/maturity. Standard refund cohorts use purchase dates; realized LTV uses in-lifetime refund timestamps. Customer activity is not application engagement DAU. Net is not store proceeds after taxes/commission.

Missing history or accounting enrichment can leave incomplete financial data. Run rate uses observed effective duration, and partial-month movement buckets have preview limitations. See [Metrics](#metrics) before escalating a supposed mismatch.

<a id="troubleshooting--cli-failure-after-creating-a-key"></a>

## CLI failure after creating a key

The CLI prints the newly created key's ID when upload fails. Check `cherrysub stores status APP_UUID` first: a network timeout can happen after save. Revoke only an unused new key in the store console; old keys were retained. Apple private keys may not be downloadable again.

For a busy CLI profile, wait for the current command. Remove the reported lock file only after verifying no command is running. Use separate signed-in profiles for concurrent jobs. [CLI recovery](#cli).


---

<a id="migration"></a>

# Migrate an existing app

Move your configuration, verify existing purchases, then release the new integration in a controlled cohort. CherrySub provides a catalog importer in the dashboard and CLI, a RevenueCat V2 catalog reader, and a resumable CLI purchase importer. These tools preserve store identifiers and stable App User IDs while making the scope of each step explicit.

<a id="migration--choose-an-import-path"></a>

## Choose an import path

| Source | What moves | Entry point |
| --- | --- | --- |
| App Store Connect | Subscriptions and one-time product identifiers and names | **Products → Import from store → App Store** |
| Google Play | Subscriptions, their base plan IDs, and modern one-time products | **Products → Import from store → Google Play** |
| RevenueCat V2 API | Matching apps' products, entitlement definitions and mappings, offerings, and packages | `cherrysub migrate revenuecat` |
| Another provider or your own backend | Configuration converted to the portable catalog CSV/JSON format | **Migration → CSV / JSON**, or `cherrysub migrate file` |
| Existing purchase evidence | Verified Apple StoreKit 2 transactions or Google purchase tokens under your existing App User IDs | `cherrysub migrate purchases` |

The current hosted product is staging. Choose the destination deployment explicitly before configuring store notifications or releasing a production client. The importers do not write to RevenueCat, change store products, cancel subscriptions, or charge customers.

<a id="migration--prepare-the-destination"></a>

## Prepare the destination

Create or select the CherrySub project and app. The app's Apple bundle ID and Google package name must match the source. An owner or admin with a verified email performs catalog imports and manages store credentials. Member accounts can inspect ordinary product data but cannot import it.

Connect Google Play before listing its products or verifying Google purchase tokens. Apple catalog reads need an **App Store Connect API key**, distinct from the **In-App Purchase key** used for server history operations. Upload the App Store Connect `.p8`, Issuer ID, and Key ID in the import screen. It is encrypted separately and only masked metadata is returned. Saving validates the credential format; **Load store products** checks the key's actual catalog access. Replacing the catalog key clears the loaded selection. Disconnecting Apple removes both managed Apple keys.

Your store account must be able to read the destination app. The importer checks its bundle/package identity, and an Apple numeric app ID, when configured. [Apple connection](#app-store) · [Google connection](#google-play).

<a id="migration--import-directly-from-a-store"></a>

## Import directly from a store

Open **Products → Import from store**, choose the store, and load its catalog. CherrySub reads every page before enabling the preview. Search the products, select the required items, and optionally attach one existing access level using a store-managed mapping. Leaving products unmapped is supported; no purchase or access-level definition is required to register them.

The preview lists additions, existing entries that will be kept, and conflicts. Apply only after reviewing it. A catalog import creates configuration; it does not create customers, purchases, or access grants. Next, check [access mappings](#access-levels) and [product sets](#product-sets) before verifying existing purchases.

The same store read is available from the CLI. Without `--apply`, it only previews the result:

```sh
cherrysub migrate store apple APP_ID
cherrysub migrate store google APP_ID --product premium,credits
cherrysub migrate store google APP_ID --product premium,credits --apply
```

The dashboard reads up to 100 pages and 1,500 products for selection. A single normalized import can contain 2,000 configuration entries in total. Products, levels, mappings, sets, and packages each count toward that limit, so mapping a large selection may require smaller batches. CLI `--product` filters exact store IDs; it still completes source pagination before creating a plan. Failed or repeated pages stop the import rather than silently applying a partial catalog.

<a id="migration--move-a-revenuecat-catalog"></a>

## Move a RevenueCat catalog

Install and sign in to the [CherrySub CLI](#cli). Use a RevenueCat **V2 secret API key** with read access to Apps, Products, Entitlements, and Offerings. Its public SDK key and V1 secret key are not substitutes. Supply the source project ID to avoid needing project-list permissions. The CLI prompts for the key without displaying it; automation can supply `CHERRYSUB_REVENUECAT_API_KEY` through its secret environment.

```sh
# Read the source and preview changes in your selected CherrySub project.
cherrysub migrate revenuecat APP_ID --source-project RC_PROJECT_ID

# Apply after reviewing the additions and confirming.
cherrysub migrate revenuecat APP_ID --source-project RC_PROJECT_ID --apply

# Export a private portable file for review in the dashboard.
cherrysub migrate revenuecat APP_ID --source-project RC_PROJECT_ID --output ./catalog.json
```

`--project` always identifies the **CherrySub** project; `--source-project` identifies the **RevenueCat** project. Bundle/package matching selects source apps. If more than one source app matches a platform, pass the intended IDs with `--source-app`. Explicit selection still must match the destination identity. Apple App Store, Mac App Store, and Google Play are supported source types; other store types need a separate mapping plan.

The source API key stays on your computer. CherrySub receives the portable configuration, not the RevenueCat key. The reader follows scoped pagination, respects bounded rate-limit retries, and fails when product associations change during export. An export has a 300-request budget plus bounded retries, 30 pages per list, and a ten-minute overall read budget. For a larger catalog, migrate smaller source selections or portable files.

RevenueCat entitlements become access levels, and offerings become product sets. Store-managed mappings preserve entitlement lookup keys. Packages receive a store suffix because CherrySub packages contain one platform/product pair: `$rc_monthly` becomes `rc_monthly_apple` or `rc_monthly_google`. The report lists these changes. An existing current product set stays selected unless you explicitly request `--activate-current`, or enable the equivalent dashboard checkbox. An imported source offering is not automatically activated.

<a id="migration--google-base-plans-and-access-policy"></a>

### Google base plans and access policy

RevenueCat can represent a subscription as `productId:basePlanId`. CherrySub separates that into the exact Google subscription product ID and the package's `base_plan_id`. Base plan metadata is preserved in product lists and current product-set responses. Your native Billing integration must use that base plan when choosing the offer token; localized pricing still comes from Google.

CherrySub currently grants access by subscription **product ID**. If source base plans for the same product grant different entitlements, the RevenueCat reader blocks the import. Review and resolve that policy before continuing; combining the grants would change customer access. Legacy Google SDK eligibility variants are skipped with a note, and unsupported conditional associations require manual review.

<a id="migration--use-the-portable-catalog-format"></a>

## Use the portable catalog format

Download the [CSV template](/import-templates/catalog.csv) or [JSON template](/import-templates/catalog.json). Convert another provider's catalog to these fields. A generic analytics or customer export is not a catalog file and is rejected if it contains unrelated columns.

```csv
platform,product_id,display_name,product_type,access_level,access_level_name,grant_mode,product_set,product_set_name,package_id,package_type,position,base_plan_id
google,premium,Premium,subscription,premium,Premium access,store_managed,default,Main offering,monthly_google,monthly,0,monthly
```

Products can be the only entities in a JSON catalog. Optional `access_levels`, `mappings`, and `product_sets` add the corresponding configuration. Use schema `version: 1`, source `manual` for your own JSON, and the exact snake-case fields in the template. Product types are `subscription`, `one_time_product`, or `unknown`. Grant modes are `store_managed` and `lifetime`. A lifetime mapping changes access policy; select it deliberately rather than inferring it from a package label.

Files are limited to 512 KiB and 2,000 normalized entries. Product IDs and display names support up to 512 bytes; product-set display names support 256. Catalog lookup keys and package identifiers use 1–128 letters, digits, dots, underscores, or hyphens. Package positions are unique within a set and range from 0 through 1,000. A Google base plan can appear on a Google package only.

```sh
cherrysub migrate template > catalog.csv
cherrysub migrate file ./catalog.csv APP_ID
cherrysub migrate file ./catalog.json APP_ID --apply
cherrysub migrate history APP_ID
```

The template contains examples. Replace their identities and product IDs before applying them. `--yes` supplies explicit noninteractive confirmation when used with `--apply`; it does not skip validation or conflict detection.

<a id="migration--understand-the-preview-and-apply-boundary"></a>

## Understand the preview and apply boundary

The preview is read-only. Its digest binds the source configuration, destination app/project, acting account, current target state, and current-set activation choice. Apply rebuilds the plan under database locks and rejects a stale digest. Refresh the preview if another administrator changes the catalog before application.

Identical entries are kept. Imports add missing configuration; they do not overwrite names, grant modes, or existing packages. An archived access key, incompatible mapping, package identifier collision, or occupied package position blocks the import. Fix the source file or explicitly edit the target through its normal management flow, then preview again.

Each application commits all additions and its summary together. Retrying the same applied request returns its existing result; a changed file cannot reuse an earlier acknowledgment. The dashboard offers a downloadable report and recent successful import history. History retains counts and actor metadata rather than uploaded files or purchase evidence. Keep the portable source and report if you need your own audit archive.

There is no one-click catalog rollback. Remove unwanted packages or mappings through their normal management screens after reviewing their impact. The importer leaves existing configuration intact and keeps the current set unchanged by default to make staged validation easier.

<a id="migration--verify-existing-purchases-with-a-checkpoint"></a>

## Verify existing purchases with a checkpoint

Preserve the exact App User IDs used by your application. Export the original purchase tokens or signed transactions from the system that retained them. A RevenueCat catalog API export does **not** include these records. RevenueCat analytics exports, entitlement flags, and subscription totals are not replacements for verifiable store evidence.

Apple inputs must be StoreKit 2 signed transaction JWS values. Legacy StoreKit 1 receipt blobs are not supported by this importer. Google inputs require `product_type` of `subscription` or `one_time_product`. Store APIs may no longer return old or expired evidence; retain your previous reporting system for history that cannot be reconstructed.

```sh
cherrysub migrate purchases --template > purchases.csv
# Populate the file with your own verified-source evidence and stable user IDs.
cherrysub migrate purchases ./purchases.csv

# Supply CHERRYSUB_APP_SECRET through a secret environment, or enter it at the prompt.
cherrysub migrate purchases ./purchases.csv --apply --checkpoint ./purchase-import.jsonl

# After interruption, use the same input file, API origin, and app secret.
cherrysub migrate purchases ./purchases.csv --apply --resume --checkpoint ./purchase-import.jsonl
```

The **app secret selects the destination app**. The dashboard selection, positional app IDs from catalog commands, and `--project` do not scope this runtime command. Use the intended `iap_sk_` key and profile/API origin. The request uses bearer authentication without sending the dashboard cookie.

Preflight reads at most 64 MiB and 20,000 records. It checks required fields and blocks a purchase assigned to different App User IDs in the same file, including different Apple transactions in the same original transaction chain. It does not verify an Apple signature locally or decide access from untrusted JWS claims. Every applied record goes to `/v1/receipts`; only an accepted, server-verified receipt with a valid event ID enters the completed checkpoint.

Completed records are skipped on resume. The private JSONL checkpoint stores input/key hashes, API origin, row numbers, row digests, and accepted event IDs. It contains no receipt, purchase token, app secret, or plaintext App User ID. An incomplete final append is recovered and that record can be retried. A lock prevents concurrent use of the same checkpoint. After an abrupt process crash, remove a stale `.lock` only after confirming no importer still runs.

Do not edit an input file while resuming it. An API-origin, key, or file mismatch rejects the checkpoint. Correct rejected records in a separate file with a new checkpoint, or retry the unchanged file after fixing destination configuration. The row report contains safe errors and HTTP status codes. Authentication failures, rate limits, service failures, and network errors stop the run; previously accepted records remain checkpointed.

Normal access-change webhook delivery may occur. This is ordinary verified ingestion, not a silent bulk-history service. Establish access mappings first and prepare downstream receivers for existing customer observations. No arbitrary grants, synthetic transactions, tax/proceeds history, aliases, paywall layouts, experiments, or targeting rules are manufactured.

<a id="migration--integrate-the-client-and-cut-over"></a>

## Integrate the client and cut over

If purchase evidence is unavailable server-side, use your application and platform's supported restore/sync path as customers return. Follow the actual SDK methods in [Receipts and restores](#receipts-and-restores); there is no assumption that every platform exposes a RevenueCat-compatible `syncPurchases` method. Returning-user sync cannot migrate customers who never reopen the app.

Keep a single owner for finishing, acknowledging, or consuming each store purchase. Test anonymous-to-authenticated transitions and verified restore ownership without assuming another provider's customer alias/merge semantics. [Customer identity](#customer-identity).

1. Compare imported product IDs, access lookup keys, package order, and Google base plans.
2. Verify existing and new sandbox purchases, renewal, expiry, refund, restore, and your intended account-switch policy.
3. Compare current access against authoritative store evidence. Track the customers and cases sampled in your own cutover checklist.
4. Configure notification destinations and webhook consumers for the intended deployment, preserving a rollback plan for retained clients.
5. Release to a controlled cohort and monitor failed verification, unfinished purchases, delivery retries, and access mismatches.
6. Retire the previous integration only after the replacement behavior is proven.

First-seen cohorts, historical revenue, and lifetime metrics can differ from a provider that observed customers earlier. CherrySub does not currently offer an automated cross-provider parity work queue or complete historical ledger import. Document the observation start date and retain source reports rather than forcing totals to match.

<a id="migration--revenuecat-comparison-and-sources"></a>

## RevenueCat comparison and sources

RevenueCat documents both server-side receipt import and client-side purchase synchronization. It also offers coordinated bulk imports and product selection from connected stores. CherrySub's current workflow adds a portable catalog preview, explicit conflict handling, atomic application, an import report, and local purchase checkpoints. These are concrete workflow features; full historical migration and provider-specific customer merging remain separate work.

The Google catalog reader uses the modern `oneTimeProducts` endpoint as well as subscriptions. This includes product configurations beyond the legacy one-time-product import restriction described in RevenueCat's current product guide. Store permissions and response availability still determine what can be listed.

Sources reviewed September 9, 2026: [RevenueCat subscription migration](https://www.revenuecat.com/docs/migrating-to-revenuecat/migrating-existing-subscriptions), [RevenueCat receipt imports](https://www.revenuecat.com/docs/migrating-to-revenuecat/migrating-existing-subscriptions/receipt-imports), [RevenueCat product import](https://www.revenuecat.com/docs/offerings/products-overview), and [Google one-time-product listing](https://developers.google.com/android-publisher/api-ref/rest/v3/monetization.onetimeproducts/list).


---

<a id="self-hosting"></a>

# Self-hosting and operations

This chapter is for operators of an authorized CherrySub source checkout. It describes the implemented processes and operational contracts. The public staging installation is not a template you can treat as a production high-availability guarantee.

<a id="self-hosting--processes-and-boundaries"></a>

## Processes and boundaries

| Process/component | Role |
| --- | --- |
| Core server (`cmd/server`) | Receipt/store ingress, projections, runtime/admin APIs, webhook worker, reconciliation |
| Web control plane (`cmd/web-control-plane`) | Hosted accounts, projects, dashboard queries, credential management, export worker |
| Web application | Next.js dashboard, public site, and documentation |
| PostgreSQL | Immutable ledger, mutable projections, memberships, sessions, durable jobs/outbox |
| Email/report gateway | Fixed-template account email and private export object/download handling |
| Reverse proxy | HTTPS termination, private origin routing, protected observability |
| Optional sandbox broker | Development bridge for checked-in sandbox applications; not your production authentication system |
| CLI | Runs on the operator/developer's computer; uses hosted APIs and local store sign-in |

Core and web control plane share the intended database and compatible encryption-key configuration. Store/receipt verification happens before the transaction that appends and reduces evidence. Webhook network delivery does not hold a database transaction open.

<a id="self-hosting--local-development"></a>

## Local development

From the backend checkout:

```sh
cp .env.example .env
docker compose up --build
```

Review the example before changing it; it contains development defaults. Change `POSTGRES_PORT` and the host `DATABASE_URL` together if the host port conflicts, or `APP_PORT` for the application port. Container-to-container addresses are separate.

For host commands, load your intended development environment, then run `make migrate`, `make dev`, `make test`, and `make lint`. `go run ./cmd/server` exposes its serve/migrate/replay/reconcile commands. The web control plane is a separate process, typically on port 8082; the core defaults to 8080. With the intended development database/environment loaded, start the hosted process in another terminal:

```sh
HTTP_ADDR=:8082 WEB_COOKIE_SECURE=false go run ./cmd/web-control-plane
```

The insecure-cookie override is for local HTTP development only. Hosted verification/invitation/reset flows still need the configured email gateway; running locally does not disable email verification or store verification.

In the web checkout, use Node 24, `npm ci`, and `API_URL` pointing at the hosted control-plane API origin that exposes `/auth` and `/dashboard`. Start with `npm run dev`. The browser also needs the intended publicly reachable runtime API for its SDK integration; these roles must not be confused.

<a id="self-hosting--core-configuration-reference"></a>

## Core configuration reference

| Variable | Purpose / default |
| --- | --- |
| `DATABASE_URL` | Required intended PostgreSQL connection |
| `HTTP_ADDR` | Listen address; core defaults to `:8080` |
| `LOG_LEVEL`, `LOG_FORMAT` | `info` and `json` defaults; text supported for development |
| `RECEIPT_RATE_LIMIT_RPS`, `RECEIPT_RATE_LIMIT_BURST` | Per-key, process-local receipt throttle; defaults 100/200 |
| `GOOGLE_PLAY_API_ENABLED` | Enables environment-backed Play reads independently of RTDN |
| `GOOGLE_APPLICATION_CREDENTIALS` | Application Default Credentials file path for environment-backed Google access |
| `GOOGLE_PUBSUB_AUDIENCE`, `GOOGLE_PUBSUB_SERVICE_ACCOUNT` | Exact authenticated push audience and identity; configured together |
| `APPLE_ISSUER_ID`, `APPLE_KEY_ID`, `APPLE_PRIVATE_KEY_B64` | Complete environment-backed Apple server-key tuple; all or none |
| `STORE_CREDENTIAL_MASTER_KEYS` | Comma-separated base64 32-byte encryption keys for managed app credentials |
| `ADMIN_API_KEYS` | Operator root bearer-key ring enabling `/admin/v1` |
| `WEBHOOK_MASTER_KEYS` | Base64 32-byte encryption-key ring for webhook signing secrets |
| `WEBHOOK_WORKERS` | Delivery concurrency; default four in the core configuration |
| `RECONCILIATION_INTERVAL` | `0` disables scheduled recovery; otherwise 1 minute–24 hours |
| `RECONCILIATION_LOOKBACK` | First-run lookback, default 24 hours; bounded 1 minute–30 days |
| `RECONCILIATION_APPLE_ENVIRONMENTS` | `production,sandbox` by default; choose the intended environments |

Managed store credentials can supply per-app reads without relying on one deployment-wide store key. The store encryption ring currently falls back to `WEBHOOK_MASTER_KEYS` when its dedicated variable is empty; configure dedicated compatible rings deliberately rather than assuming unrelated processes can decrypt each other's records.

<a id="self-hosting--hosted-web-and-emailreport-configuration"></a>

## Hosted web and email/report configuration

The web control plane needs `DATABASE_URL`, its `HTTP_ADDR`, and the relevant store/webhook master keys. `WEB_SESSION_TTL` defaults to seven days and accepts one minute through 30 days. Keep `WEB_COOKIE_SECURE` enabled behind production-shaped HTTPS. `EXPORT_WORKERS` defaults to one and is bounded from one through eight. The hosted process accepts `SHUTDOWN_WINDOW` from one second to one minute (default ten seconds); the core uses its ten-second configured shutdown default.

Configure `EMAIL_GATEWAY_URL`, `EMAIL_GATEWAY_SECRET`, and `PUBLIC_WEB_URL` together. The gateway secret must match its deployment; the public web origin controls allowed account-action destinations. Without delivery configuration, email-dependent flows/exports are unavailable rather than pretending mail was sent.

The checked-in email/report gateway is a Cloudflare Worker with an email-sending binding and a private R2 `REPORTS` binding. Its public action/report origins and allowed sender are explicit configuration. Provision the appropriate sender and private bucket, store the shared secret in the platform secret store, and run that package's `npm ci` / `npm run check` before the approved environment's deployment. Its checked-in production environment is not configured.

After a gateway rollout, check health, unauthenticated request rejection, one authorized test-account email, and a bounded test report upload/download. Export links expire after 24 hours; private bucket lifecycle cleanup is a backstop, not the primary link authorization mechanism.

<a id="self-hosting--deploy-the-origin"></a>

## Deploy the origin

Use the staging Compose/Caddy templates as a topology reference: expose HTTPS through the proxy, keep PostgreSQL and application ports private, run migrations deliberately, and wait for readiness before routing traffic. Immutable image tags, versioned configuration, and backups make rollback concrete.

Before a release, back up the database, record actual image references/configuration, retain required encryption keys, run formatting/tests/static analysis, validate Compose/proxy configuration, and check readiness on the new version. Production requires its own reviewed blue/green or equivalent rollout, compatible expand/contract migrations, origin protection, off-site recovery, and availability targets. Two processes on one host do not protect against host failure.

The production topology document records a preferred direction; it does not mean a multi-origin deployment is currently live. Never infer authorization to mutate another environment from the word staging in a command example.

<a id="self-hosting--reconciliation-and-missed-observations"></a>

## Reconciliation and missed observations

Run a bounded recovery pass with the core server command:

```sh
go run ./cmd/server reconcile --app-id APP_UUID
```

Scheduled recovery uses `RECONCILIATION_INTERVAL`. Successful streams retain cursors and intentionally overlap five minutes. Apple recovers signed notification history for selected environments. Google recovers void/refund evidence and bounded order-accounting enrichment. Each stream advances only after its full page set succeeds; one app/store failure does not stop every stream.

A PostgreSQL advisory lock prevents concurrent ownership of the same reconciliation run across replicas. Recovery does not bypass verification, invent another provider's history, or treat Google order-accounting data as an independent access decision.

<a id="self-hosting--replay-and-projection-repair"></a>

## Replay and projection repair

Start with read-only verification:

```sh
go run ./cmd/server replay --verify --app-id APP_UUID
```

Replay rebuilds projections in an unlogged shadow schema, checks drift, and compares derived events without replacing live tables. It uses a global advisory lock and brief safe ledger-boundary locks; do not launch competing jobs into the fixed shadow schema.

Only after reviewing the report and establishing a recoverable backup should an operator deliberately use `replay --apply`. Apply performs the final compared projection replacement; it is not an event-history edit. Keep the original source ledger and mapping-version rules intact.

<a id="self-hosting--health-metrics-and-response"></a>

## Health, metrics, and response

Core `/livez` reports process liveness. `/readyz` and `/healthz` check database readiness. The web control plane has its own `/healthz`. `/metrics` is for protected internal scraping and is blocked by the staging public proxy.

Monitor ingest acceptance/deduplication/rejection/failure, receipt throttling, webhook attempts/duration/disabled endpoints, reconciliation runs/records/duration, export job failures, and database health. Labels are bounded; avoid placing customer tokens or private store payloads in logs/metrics.

For an incident, preserve evidence, identify the failing dependency, stop uncontrolled retry amplification, and restore the last known-good routing/configuration when needed. Test database and key restoration rather than relying only on a successful backup command. [Troubleshooting](#troubleshooting) · [Security](#security).


---

<a id="security"></a>

# Security and data handling

CherrySub verifies store evidence before app attribution and persists immutable event history with mutable projections. Its boundaries separate service operators, hosted project users, app servers, and public clients. These are implemented controls, not a blanket certification or guarantee of compliance for every deployment.

<a id="security--account-and-project-access"></a>

## Account and project access

Hosted browser and CLI sessions use opaque `JSESSIONID` values. Server-side records retain token hashes and enforce expiry, email verification, project membership, and operation-specific roles. Owners/admins configure project resources; viewers inspect them. Personal saved analyses and export requests also bind to the requesting account.

Passwords are hashed with bcrypt. Verification, reset, and invitation links use bounded-lifetime opaque tokens with hash storage. Password reset and session-revocation flows invalidate their documented sessions. A copied URL does not grant project access.

<a id="security--application-credentials"></a>

## Application credentials

App API keys are shown once and stored as digests plus safe hints. Public `iap_pk_` keys require an app/customer-bound `iap_ct_` token for runtime operations. The token's subject must match the receipt or subscriber identity before any store I/O or ownership transition.

`iap_sk_` stays on the authenticated application backend. Root admin keys configure the operator boundary and are not a substitute for hosted project login. Do not ship either kind of server authority in public client bundles, generated documentation examples, analytics events, or support notes.

<a id="security--store-verification"></a>

## Store verification

Apple signed evidence is checked against the pinned Apple root/certificate-purpose chain, ES256 signatures, and online certificate status. Nested signed transaction/renewal data is independently verified and cross-checked against app identity/environment. Production includes the signed numeric app ID check. A certificate responder outage is retriable; it does not activate a runtime verification bypass.

Google Pub/Sub ingress checks the OIDC signature, exact configured audience, expected service-account email, and verified email claim before processing the notification. Purchase notification numbers are a doorbell; an authenticated Android Publisher read supplies authoritative purchase state.

Invalid or unknown evidence is not trusted to select another app. Rejected-delivery metadata is bounded and separated from trusted purchase history.

<a id="security--encrypted-secrets-and-rotation"></a>

## Encrypted secrets and rotation

Managed store credentials and webhook signing secrets are encrypted at rest with configured master-key rings. API responses expose safe metadata rather than plaintext. Operators must keep matching encryption configuration across the processes that read/write those secrets.

Prepend a new master key while retaining decryption coverage for old encrypted records. Re-encrypt/rotate the affected managed secrets using the supported processes and verify startup coverage before removing old keys. A database backup without its corresponding encryption keys is not sufficient for recovery.

Webhook secret rotation signs with overlapping old/new secrets for 24 hours. The CLI keeps Apple passwords, Apple session cookies, and Google OAuth tokens local to the process and does not store them in profiles. Its saved session profiles use private file permissions on macOS/Linux and normal private user-directory ACLs on Windows.

<a id="security--webhook-and-export-boundaries"></a>

## Webhook and export boundaries

Outbound delivery requires public HTTPS destinations, rejects private/special networks, rechecks DNS when connecting, and refuses redirects. Receivers still need raw-body signature verification, timestamp validation, durable idempotency, and safe downstream processing.

Exports contain normalized report fields, not raw credentials or complete ledger payloads. Private object storage, token-hash metadata, expiring fragment-token download links, no-store responses, and spreadsheet-formula neutralization reduce unintended disclosure. Anyone intentionally given a still-valid download token may be able to retrieve its report; treat that link as a secret.

<a id="security--retention-and-deletion"></a>

## Retention and deletion

The ledger is append-only and application disable does not erase its history. Deleting a support note or saved view affects that object, not customer purchases. The current product does not expose a general self-service customer-data erasure workflow.

Data-retention periods, backup retention, access review, incident response, and legal/compliance obligations remain deployment/operator policies. Do not infer a promised retention period, data residency guarantee, or certification from the existence of an EU staging host or encrypted storage.

<a id="security--operational-checks"></a>

## Operational checks

Keep database/app ports private behind HTTPS, protect metrics, apply deployment-wide rate limits in addition to process-local receipt throttling, back up data and required keys separately under controlled access, and test restoration. Use supported dependency/toolchain patches and verify releases before changing live routing.

Production topology, high availability, and zero-downtime requirements are separate from the current single-origin staging setup. [Self-hosting](#self-hosting) · [Limits and status](#limits-and-release-status).


---

<a id="limits-and-release-status"></a>

# Limits and release status

This handbook describes the staging preview reviewed on September 9, 2026. It distinguishes implemented behavior from unavailable features and source-integrated packages from public registry releases.

<a id="limits-and-release-status--current-distribution"></a>

## Current distribution

| Surface | Status |
| --- | --- |
| Hosted dashboard and API | Staging preview at app-staging.cherrysub.com / api-staging.cherrysub.com |
| CherrySub CLI | Standalone macOS, Linux, and Windows builds for amd64/arm64; defaults to staging |
| Swift package | Source-integrated AppVisionIAP; Apple platform minimums are in its guide |
| Kotlin core and Billing adapter | Source-integrated 0.5.0 coordinates |
| TypeScript / Dart core | Source-integrated 0.5.0; public registry publication disabled |
| Flutter storefront adapter | Source-integrated 0.3.0 consuming Dart core 0.5.0 |
| Trusted Go SDK | Source/module package, version 0.2.0 |
| Production hosting | Planned/reviewed separately; staging is not a production availability commitment |

The AppVisionOS import/package names are the current code names. Do not replace them with invented `@cherrysub/*` or pub.dev/Maven coordinates in your build.

<a id="limits-and-release-status--concrete-limits"></a>

## Concrete limits

| Boundary | Limit or default |
| --- | --- |
| Runtime receipt/ingest request body | 256 KiB |
| Hosted account/dashboard JSON body | 32 KiB; catalog-import endpoints alone accept 1 MiB envelopes |
| Catalog import source file | 512 KiB, 2,000 normalized configuration entries |
| Dashboard store selection | Up to 100 pages and 1,500 products; import entry limit still applies |
| CLI purchase file | 64 MiB, 20,000 records; StoreKit 2 JWS or Google tokens |
| App User ID | 1–512 UTF-8 bytes |
| Customer-token lifetime | Default 3600 seconds; 300–86400 allowed |
| Hosted session lifetime | Default seven days; operator-configurable between one minute and 30 days |
| Verification / reset / invitation link | 24 hours / 30 minutes / seven days |
| Password | 10–72 bytes at the backend; uppercase, lowercase, and special character required |
| Apple credential validator | PEM up to 16 KiB |
| Google credential validator | Service-account JSON up to 64 KiB, subject to the lower hosted-envelope ceiling |
| Customer support notes | 20 per customer; 1000 UTF-8 bytes per note |
| Dashboard dataset page size | Default 50, maximum 100 |
| Relative chart windows | 7, 28, 90, 365 days |
| Custom chart windows | Daily 366 days; weekly five years; monthly ten years |
| Interactive chart query/cache | Eight-second DB deadline; 30-second in-process cache |
| Overview history | Latest 180 UTC days |
| Export requests | One active job/account; ten requests/project/account per rolling 24 hours |
| Export output | 500,000 rows, 32 MiB, ten-minute query deadline |
| Export retries / download link | At most five lease attempts / 24 hours |
| Product package position | Integer 0–1000, unique within its set |
| Webhook event filters | At most 100; default access.changed |
| Outbound derived payload / response excerpt | 20 KiB / 64 KiB |
| Webhook retry window / secret overlap | 72 hours / 24 hours |
| Default core receipt throttle | 100 requests/second, burst 200 per API-key hash, per process; deployment overrides can be lower |

The Google validator's 64 KiB maximum is not the effective hosted upload allowance: JSON escaping and the 32 KiB hosted request-body cap can reject a large file earlier. Normal service-account key files are much smaller; a large-file failure must not be presented as a successful save.

<a id="limits-and-release-status--store-setup-boundaries"></a>

## Store setup boundaries

CLI Google browser authorization needs an organization-provided Desktop OAuth client. A CherrySub-owned verified OAuth client is not bundled. Apple automated sign-in uses Apple's web-session endpoints and may need console action when those endpoints, agreements, or key limits change.

CLI setup provisions credentials; it does not currently create complete RTDN infrastructure. Google push audience/service-account verification is deployment-level. Store notification wiring and an actual sandbox purchase remain separate validation steps.

Automated setup has local protocol/flow tests and staging API contract checks. The preview's original provisioning implementation was not validated by creating keys in a real Apple/Google account during development. Operators should validate that final step with their own authorized store account.

<a id="limits-and-release-status--reporting-boundaries"></a>

## Reporting boundaries

Currencies remain separate; net revenue is before any unmodeled taxes/commission. There is no predictive LTV, tax-net proceeds, global exchange-rate normalization, or complete history invented from a recent import.

Run rate uses effective observed service duration, including its extension effects. Partial-month movement calculations can include broader bucket boundaries. Review source evidence for precise partial-period or financial-close comparisons. Cohort windows and maturity are deliberate and differ across metrics.

<a id="limits-and-release-status--features-not-offered-by-this-version"></a>

## Features not offered by this version

There is no hosted paywall layout editor, A/B assignment, arbitrary customer merge/alias-management API, manual per-customer premium override, store refund-issuance console, general bulk provider-history import, or self-service customer/project data deletion flow.

Product sets present ordered store products; they do not include user targeting, localized storefront price computation, or store offer creation. Store restore/ownership behavior must be tested against your application's account policy rather than assumed identical to another provider.

<a id="limits-and-release-status--how-to-evaluate-a-change"></a>

## How to evaluate a change

Use the [test matrix](#testing), [metric definitions](#metrics), and complete [API contract](#api-reference). For a missing capability, define the exact user flow and required evidence before treating an interface label or a future production design as implemented functionality.

<a id="limits-and-release-status--migration-scope"></a>

## Migration scope

RevenueCat catalog reads, connected-store product import, portable CSV/JSON catalog plans, and resumable verified-purchase CSV ingestion are available in the current staging implementation. Catalog imports are additive and atomic. Google subscription base plan IDs can be preserved in imported packages, but access is still mapped by subscription product; differing base-plan entitlement policies require review and block automatic RevenueCat import.

There is no automatic paywall, experiment, targeting, customer-alias, or complete historical financial import. The purchase command performs normal verified receipt ingestion, can emit webhooks, and does not support legacy Apple StoreKit 1 receipt blobs. Provider history and token-retention limits still apply. [Migration guide](#migration).
