diff --git a/content/docs/dashboard/dashboard-settings/meta.json b/content/docs/dashboard/dashboard-settings/meta.json
index 07654789..d2abb0cf 100644
--- a/content/docs/dashboard/dashboard-settings/meta.json
+++ b/content/docs/dashboard/dashboard-settings/meta.json
@@ -8,6 +8,7 @@
"overview-settings-revenue-tracking",
"overview-settings-refund-protection",
"overview-settings-audit-log",
+ "overview-settings-access-controls",
"overview-settings-team",
"overview-settings-projects",
"overview-settings-all-teams",
@@ -16,4 +17,4 @@
"!overview-settings-apple-search-ads",
"..."
]
-}
\ No newline at end of file
+}
diff --git a/content/docs/dashboard/dashboard-settings/overview-settings-access-controls.mdx b/content/docs/dashboard/dashboard-settings/overview-settings-access-controls.mdx
new file mode 100644
index 00000000..146f6ba4
--- /dev/null
+++ b/content/docs/dashboard/dashboard-settings/overview-settings-access-controls.mdx
@@ -0,0 +1,122 @@
+---
+title: "Access Controls"
+description: "Manage organization roles, project access, and scoped API keys."
+---
+
+Use **Access Controls** to decide who can work inside your organization, which projects they can access, and what organization API keys are allowed to do.
+
+Access controls apply at the organization level. You can give a member or API key access to every project, or restrict it to specific projects.
+
+
+
+### Opening Access Controls
+
+Open an app and go to **Settings > Team** to manage member roles and project access.
+
+Use these settings pages for access management:
+
+| Page | Use it to |
+| --- | --- |
+| Team | Invite teammates, update organization roles, and restrict members to specific projects. |
+| API Keys | Create, update, or revoke organization API keys with selected scopes and project access. |
+
+Only **Owners** and **Admins** can manage access. Owners can manage any role, including other Owners. Admins can manage most members and API keys, but they cannot assign or manage the Owner role.
+
+
+If an Admin is restricted to specific projects, they can only manage access for projects they can already access. Restricted Admins cannot grant unrestricted organization access.
+
+
+### Organization roles
+
+Organization roles control the maximum set of actions a member can take. Project access can narrow where those actions apply, but it cannot grant permissions beyond the member's organization role.
+
+| Role | What it can do |
+| --- | --- |
+| Owner | Full organization control. Owners can manage billing, settings, access controls, API keys, and other Owners. Owners always have access to all projects. |
+| Admin | Full working access and access-management permissions, except for managing Owners. Admins can be restricted to specific projects. |
+| User (Legacy) | Legacy Admin-level role kept for backward compatibility. Treat this as full access and reassign it when possible. |
+| Editor | Can create and edit paywalls, campaigns, notifications, and assets. Editors can view related resources, but cannot manage access or sensitive organization settings. |
+| Reader | Read-only visibility into dashboard resources. Readers cannot create, update, or delete resources. |
+| Analyst | Read-only, analytics-focused visibility for stakeholders who need reporting access without edit permissions. |
+
+### Project access
+
+Each member has one project access mode:
+
+| Mode | What it means |
+| --- | --- |
+| All Projects | The member can access every current and future project allowed by their organization role. |
+| Restricted | The member can only access the projects you assign to them. |
+
+When a member is **Restricted**, assign one role for each project they can access:
+
+| Project role | Use it for |
+| --- | --- |
+| Admin | Project-level management access. |
+| Editor | Editing resources inside the project. |
+| Viewer | Read-only access to the project. |
+
+
+Project roles are capped by the organization role. For example, a Reader with a Project Admin grant is still read-only because the organization role does not allow writes.
+
+
+Use the **Project access** dropdown when inviting or editing a member to choose **Restricted**. When selected, Superwall shows the project assignments and project role controls for that member.
+
+
+
+### Invite a member
+
+1. Open **Settings > Team**.
+2. Click **Invite member**.
+3. Enter the member's name and email.
+4. Choose an organization role.
+5. Choose **All Projects** or **Restricted**.
+6. If restricted, select the projects they can access and choose a project role for each one.
+7. Click **Invite**.
+
+The invite appears as pending until the user accepts it.
+
+### Update a member
+
+From **Settings > Team**, click **Edit** next to a member. You can change their organization role, project access mode, and project assignments.
+
+Owners cannot remove or demote the last Owner in an organization. Admins cannot assign the Owner role or edit existing Owners.
+
+### API key access
+
+Organization API keys use the same access model:
+
+| Setting | What it controls |
+| --- | --- |
+| Scopes | Which resources the key can read or write, such as paywalls, campaigns, products, webhooks, charts, users, assets, or access controls. |
+| Project Access | Whether the key can operate across all projects or only selected projects. |
+
+Both checks must pass. For example, an API key with `paywalls:write` and **Restricted** access to one project can only update paywalls in that project.
+
+In the create key dialog, choose the scopes first, then use **Project access** to decide whether the key can access all projects or only selected projects.
+
+
+
+When you create a key, Superwall shows the token once. Copy it before closing the dialog. After that, the dashboard only shows a masked token.
+
+### Revoke or update an API key
+
+Use **Settings > API Keys** to review each key's scopes, project access, creation date, and last-used timestamp. Edit the key to change its scopes or project restrictions, or revoke it when it is no longer needed.
+
+
+Prefer restricted API keys for automation. Give each service only the scopes and projects it needs.
+
+
+### Troubleshooting
+
+If a member cannot see a project, confirm that their project access mode is **All Projects** or that the project is selected in their restricted assignments.
+
+If an API request is denied, check both the key's scopes and its project access. The key needs the correct resource scope and access to the target project.
+
+If you cannot assign an Owner, make sure you are signed in as an Owner. Admins cannot grant or manage Owner access.
+
+### Related
+
+- [Team settings](/dashboard/dashboard-settings/overview-settings-team)
+- [Projects](/dashboard/dashboard-settings/overview-settings-projects)
+- [Keys](/dashboard/dashboard-settings/overview-settings-keys)
diff --git a/content/docs/dashboard/dashboard-settings/overview-settings-keys.mdx b/content/docs/dashboard/dashboard-settings/overview-settings-keys.mdx
index b10fb6b1..4869ae21 100644
--- a/content/docs/dashboard/dashboard-settings/overview-settings-keys.mdx
+++ b/content/docs/dashboard/dashboard-settings/overview-settings-keys.mdx
@@ -12,6 +12,10 @@ You'll use your API key to initialize the Superwall client in your apps. Each on

+
+These are public SDK keys used to configure Superwall in your app. For scoped organization API keys used by servers, CI jobs, or other backend automation, use [Access Controls](/dashboard/dashboard-settings/overview-settings-access-controls).
+
+
### Session starts by SDK version
Use the session starts by SDK chart to see how many sessions are being started, split out by the SDK version of Superwall.
diff --git a/content/docs/dashboard/dashboard-settings/overview-settings-team.mdx b/content/docs/dashboard/dashboard-settings/overview-settings-team.mdx
index 3561d6e3..16707532 100644
--- a/content/docs/dashboard/dashboard-settings/overview-settings-team.mdx
+++ b/content/docs/dashboard/dashboard-settings/overview-settings-team.mdx
@@ -4,7 +4,7 @@ title: "Team"
In the **Team** section within **Settings**, you can view and edit your Superwall team:
-Team members have access to all of your apps within Superwall, making collaboration seamless.
+Team members can collaborate across your Superwall organization. By default, members can access all projects allowed by their role. To restrict members to specific projects, use [Access Controls](/dashboard/dashboard-settings/overview-settings-access-controls).
### Invite users
@@ -16,7 +16,7 @@ Once the user accepts the invite, they'll show up in your Team section. You can
### Team roles
-Only **Owners** or **Admins** can change team member roles.
+Only **Owners** or **Admins** can change team member roles. For project-level restrictions and API key permissions, use [Access Controls](/dashboard/dashboard-settings/overview-settings-access-controls).
#### Owner — Full control
@@ -31,18 +31,13 @@ Only **Owners** or **Admins** can change team member roles.
- Can perform most administrative actions
- Can invite/remove team members
-- Cannot assign or change Owner roles (can only assign Admin, Editor, or Reader)
+- Cannot assign or change Owner roles
- Access to sensitive features like webhook destinations
- Full create/update/delete permissions on paywalls, campaigns, products, etc.
#### Editor — Can create and modify content
-- Can read, create, and update:
- - Paywalls and paywall triggers
- - Campaigns and A/B tests
- - Products
- - Notifications
- - Projects
+- Can create and update paywalls, campaigns, notifications, and assets
- Can view applications and organizations
- Cannot delete applications
- Cannot access team management (invite/remove members)
@@ -54,15 +49,21 @@ Only **Owners** or **Admins** can change team member roles.
- Cannot create, update, or delete anything
- Useful for stakeholders who need visibility but shouldn't make changes
+#### Analyst — Analytics-focused visibility
+
+- Can view analytics and reporting surfaces
+- Cannot create, update, or delete resources
+- Useful for finance, data, or growth stakeholders who need visibility without edit access
+
#### User (Legacy)
-The User role is a legacy role kept for backward compatibility. It has the same permissions as Admin. New team members should be assigned one of the roles listed above instead.
+The User role is a legacy role kept for backward compatibility. It has the same permissions as Admin. Use Admin, Editor, Reader, or Analyst for new assignments when possible.
- Has the same permissions as Admin
- Exists only for backward compatibility with accounts created before the current role system
-- If you see team members with the User role, consider reassigning them to the appropriate role (Owner, Admin, Editor, or Reader)
+- If you see team members with the User role, consider reassigning them to the appropriate role
### Renaming your team
diff --git a/content/docs/dashboard/products.mdx b/content/docs/dashboard/products.mdx
index 6251110c..0c037dae 100644
--- a/content/docs/dashboard/products.mdx
+++ b/content/docs/dashboard/products.mdx
@@ -2,7 +2,7 @@
title: "Adding Products"
---
-Add your existing products from their respective storefront, such as the App Store or the Google Play Store, to an app so they can be used in one or more paywalls. For adding Stripe products, please view [this doc](/web-checkout/web-checkout-adding-a-stripe-product).
+Add your existing products from their respective storefront, such as the App Store or the Google Play Store, to an app so they can be used in one or more paywalls. For adding Stripe products, please view [this doc](/web-checkout/web-checkout-adding-a-stripe-product). For iOS products purchased through your own billing system, see [Custom Store Products](/ios/guides/custom-store-products).
Before you attempt to test a paywall on iOS via TestFlight, make sure they are in the "Ready to Submit" phase if it's their initial launch. For local testing, you can use a [StoreKit configuration file](/sdk/guides/testing-purchases) at any point.
@@ -26,7 +26,7 @@ From there, you have five fields to fill out:
| Field | Description |
| ---------- | ---------------------------------------------------------------------------------------------------------- |
-| Identifier | The StoreKit or Google Play product identifier for your product. |
+| Identifier | The StoreKit, Google Play, or custom billing product identifier for your product. |
| Trial | The trial duration attached to the product, if any. |
| Price | The price attached to the product. Either type one in, or just the dropdown to select common price points. |
| Period | The length of the subscription. |
@@ -34,9 +34,7 @@ From there, you have five fields to fill out:
When you're done, click **Save**.
-Note that the pricing information you enter here is **only** used in the Paywall Editor. On
-device, that information is pulled directly from the App Store or Google Play Store and will be
-localized.
+Note that the pricing information you enter here is **only** used in the Paywall Editor for App Store and Google Play products. On device, that information is pulled directly from the App Store or Google Play Store and will be localized. For iOS Custom Store Products, the SDK uses the product metadata from Superwall and routes purchase attempts to your `PurchaseController`.
Take care to make sure your product identifier is correct and matches its storefront. This is the
diff --git a/content/docs/images/rbac_api.jpg b/content/docs/images/rbac_api.jpg
new file mode 100644
index 00000000..843663d5
Binary files /dev/null and b/content/docs/images/rbac_api.jpg differ
diff --git a/content/docs/images/rbac_invite.jpg b/content/docs/images/rbac_invite.jpg
new file mode 100644
index 00000000..809cd66d
Binary files /dev/null and b/content/docs/images/rbac_invite.jpg differ
diff --git a/content/docs/images/rbac_teams.jpg b/content/docs/images/rbac_teams.jpg
new file mode 100644
index 00000000..d55b05c3
Binary files /dev/null and b/content/docs/images/rbac_teams.jpg differ
diff --git a/content/docs/ios/guides/custom-store-products.mdx b/content/docs/ios/guides/custom-store-products.mdx
new file mode 100644
index 00000000..b7dca8c6
--- /dev/null
+++ b/content/docs/ios/guides/custom-store-products.mdx
@@ -0,0 +1,157 @@
+---
+title: "Custom Store Products"
+description: "Sell products from non-App Store billing systems on iOS paywalls using a PurchaseController."
+---
+
+Custom Store Products let an iOS paywall sell products that are not backed by StoreKit. Use them when the checkout is owned by your app, your server, Stripe, a web flow, or another billing system, but you still want the product to appear on a Superwall paywall with product variables, trial eligibility, and purchase tracking.
+
+
+Custom Store Products require iOS SDK `4.15.0` or later and a [`PurchaseController`](/ios/sdk-reference/PurchaseController). If your app does not configure a purchase controller, custom product purchases will fail because there is no App Store product for Superwall to purchase.
+
+
+## How It Works
+
+When a paywall contains a Custom Store Product, Superwall:
+
+1. Loads the product metadata from Superwall instead of StoreKit.
+2. Makes the product available to paywall variables such as `products.primary.price`, `products.selected.period`, and trial variables.
+3. Checks trial eligibility using the product's entitlements and the user's entitlement history.
+4. Calls your `PurchaseController` when the user starts the purchase.
+5. Tracks the purchase result that your controller returns.
+
+Your app is responsible for the actual checkout and entitlement state. After a successful external purchase, update `Superwall.shared.subscriptionStatus` so Superwall knows whether the user should keep seeing paywalls.
+
+
+If you want Superwall's hosted Stripe web checkout and redemption flow, use [Web Checkout](/ios/guides/web-checkout). Custom Store Products are for purchase flows that your app handles from `PurchaseController`.
+
+
+## Add a PurchaseController
+
+Pass a `PurchaseController` when configuring Superwall:
+
+```swift Swift
+let purchaseController = CustomStorePurchaseController()
+
+Superwall.configure(
+ apiKey: "MY_API_KEY",
+ purchaseController: purchaseController
+)
+```
+
+Inside `purchase(product:)`, StoreKit-backed products contain either `sk1Product` or `sk2Product`. Custom Store Products do not, so route them to your external billing system using `product.productIdentifier`.
+
+```swift Swift
+import SuperwallKit
+
+final class CustomStorePurchaseController: PurchaseController {
+ func purchase(product: StoreProduct) async -> PurchaseResult {
+ if hasStoreKitProduct(product) {
+ return await Superwall.shared.purchase(product)
+ }
+
+ do {
+ let result = try await BillingClient.shared.purchase(
+ productIdentifier: product.productIdentifier
+ )
+
+ switch result {
+ case .purchased:
+ await syncSubscriptionStatus()
+ return .purchased
+ case .pending:
+ return .pending
+ case .cancelled:
+ return .cancelled
+ }
+ } catch {
+ return .failed(error)
+ }
+ }
+
+ func restorePurchases() async -> RestorationResult {
+ do {
+ try await BillingClient.shared.restorePurchases()
+ await syncSubscriptionStatus()
+ return .restored
+ } catch {
+ return .failed(error)
+ }
+ }
+
+ private func hasStoreKitProduct(_ product: StoreProduct) -> Bool {
+ if product.sk1Product != nil {
+ return true
+ }
+
+ if #available(iOS 15.0, *), product.sk2Product != nil {
+ return true
+ }
+
+ return false
+ }
+
+ private func syncSubscriptionStatus() async {
+ let activeProductIds = await BillingClient.shared.activeProductIdentifiers()
+ let entitlements = Superwall.shared.entitlements.byProductIds(activeProductIds)
+
+ await MainActor.run {
+ Superwall.shared.subscriptionStatus = entitlements.isEmpty
+ ? .inactive
+ : .active(entitlements)
+ }
+ }
+}
+```
+
+Replace `BillingClient` with your own billing implementation. It should start checkout for `product.productIdentifier`, report cancellation and pending states distinctly when possible, and expose the active product identifiers that should unlock Superwall entitlements.
+
+## Keep Entitlements In Sync
+
+Superwall decides whether a user is active from `subscriptionStatus`, not from the external payment provider directly. When your billing system says the user has access, map the active product identifiers back to Superwall entitlements and set the status:
+
+```swift Swift
+let activeProductIds: Set = ["pro_monthly_external"]
+let entitlements = Superwall.shared.entitlements.byProductIds(activeProductIds)
+
+Superwall.shared.subscriptionStatus = entitlements.isEmpty
+ ? .inactive
+ : .active(entitlements)
+```
+
+Call this after purchase, after restore, on app launch, and whenever your billing provider reports a subscription or entitlement change.
+
+
+Make sure the product identifier returned by your billing system matches the product identifier configured in Superwall. If the identifier does not match, `entitlements.byProductIds(_:)` will not find the entitlement and the user can remain inactive after purchase.
+
+
+## Trial Eligibility
+
+Custom Store Products can use the same trial variables as App Store products. Superwall checks the custom product's trial metadata and associated entitlements, then looks at the user's entitlement history to avoid showing a trial to someone who has already had access.
+
+For best results:
+
+- Attach at least one entitlement to each custom subscription product.
+- Keep `subscriptionStatus` current before presenting paywalls.
+- Return `.pending` when the external checkout requires more user action.
+- Return `.cancelled` when the user intentionally exits checkout.
+
+If customer information has not loaded yet, Superwall avoids treating the user as eligible for a custom-product trial.
+
+## What Not To Do
+
+- Do not call `Superwall.shared.purchase(product)` for a custom product. That helper is for StoreKit-backed products.
+- Do not fetch custom products with `Superwall.shared.products(for:)`; that method fetches App Store products.
+- Do not rely on `sk1Product` or `sk2Product` for a custom product. Use `product.productIdentifier`.
+- Do not wait until the next app launch to update `subscriptionStatus` after purchase.
+
+## Testing
+
+Test the full flow on a paywall that contains your Custom Store Product:
+
+1. Confirm the product's price and trial copy render on the paywall.
+2. Tap the product and verify your `PurchaseController` receives the product identifier.
+3. Complete, cancel, fail, and mark a purchase pending in your external billing test environment.
+4. Confirm your app updates `subscriptionStatus` after purchase and restore.
+5. Confirm users who have already held the entitlement do not see a custom-product trial as available.
+
+For general purchase-controller setup, see [Advanced Purchasing](/ios/guides/advanced-configuration).
diff --git a/content/docs/ios/guides/direct-purchasing.mdx b/content/docs/ios/guides/direct-purchasing.mdx
index 404063a0..29d53f0c 100644
--- a/content/docs/ios/guides/direct-purchasing.mdx
+++ b/content/docs/ios/guides/direct-purchasing.mdx
@@ -11,6 +11,10 @@ let result = await Superwall.shared.purchase(product)
This method takes a `StoreProduct` and returns a `PurchaseResult` so you can take action on the result. Here's an example from our demo app, [Caffeine Pal](https://github.com/superwall/CaffeinePal/blob/using-superwall-sdk/Caffeine%20Pal/Store%20and%20Models/CaffeineStore.swift#L121):
+
+`Superwall.shared.purchase(product)` is for StoreKit-backed products. For Custom Store Products on a paywall, handle the purchase in your [`PurchaseController`](/ios/sdk-reference/PurchaseController) using `product.productIdentifier`. See [Custom Store Products](/ios/guides/custom-store-products).
+
+
```swift
func purchase(_ product: StoreProduct) async throws {
let result = await Superwall.shared.purchase(product)
@@ -44,7 +48,7 @@ Here's an example:
### Fetch products
-A `StoreProduct` can be fetched using its corresponding identifier from App Store Connect or a [StoreKit Configuration File](/ios/guides/testing-purchases). For example, `subscription.caffeinePalPro.monthly` here:
+A `StoreProduct` can be fetched using its corresponding identifier from App Store Connect or a [StoreKit Configuration File](/ios/guides/testing-purchases). Custom Store Products are loaded from paywalls and are not fetched with `products(for:)`. For example, `subscription.caffeinePalPro.monthly` here:

@@ -79,4 +83,4 @@ case .failed(let error):
}
```
-There are a number of additional ways to respond to a purchase outside of this `result`, depending on how the product was purchased (for example, within a paywall). For examples, see this [doc](/ios/guides/advanced/viewing-purchased-products).
\ No newline at end of file
+There are a number of additional ways to respond to a purchase outside of this `result`, depending on how the product was purchased (for example, within a paywall). For examples, see this [doc](/ios/guides/advanced/viewing-purchased-products).
diff --git a/content/docs/ios/meta.json b/content/docs/ios/meta.json
index 3ddcb55a..ab1ecc07 100644
--- a/content/docs/ios/meta.json
+++ b/content/docs/ios/meta.json
@@ -18,6 +18,7 @@
"---Common Use Cases---",
"guides/web-checkout",
"guides/advanced-configuration",
+ "guides/custom-store-products",
"guides/configuring",
"guides/using-superwall-delegate",
"guides/3rd-party-analytics",
diff --git a/content/docs/ios/sdk-reference/PurchaseController.mdx b/content/docs/ios/sdk-reference/PurchaseController.mdx
index 75124346..2bec4101 100644
--- a/content/docs/ios/sdk-reference/PurchaseController.mdx
+++ b/content/docs/ios/sdk-reference/PurchaseController.mdx
@@ -53,6 +53,8 @@ When using a PurchaseController, you must also manage [`subscriptionStatus`](/io
- For custom products introduced in `4.15.0`, Superwall will call your purchase controller with a `StoreProduct` that has no StoreKit backing product. In that case, use `product.productIdentifier` in your external billing system and return the matching `PurchaseResult`.
- Do not call `Superwall.shared.purchase(product)` for custom products. That helper is for StoreKit-backed purchases only.
+For a complete guide, see [Custom Store Products](/ios/guides/custom-store-products).
+
## Usage
For implementation examples and detailed guidance, see [Using RevenueCat](/ios/guides/using-revenuecat).
diff --git a/content/docs/support/dashboard/what-is-the-legacy-user-role-in-team-settings.mdx b/content/docs/support/dashboard/what-is-the-legacy-user-role-in-team-settings.mdx
index 7e285c73..7cb6835d 100644
--- a/content/docs/support/dashboard/what-is-the-legacy-user-role-in-team-settings.mdx
+++ b/content/docs/support/dashboard/what-is-the-legacy-user-role-in-team-settings.mdx
@@ -11,8 +11,9 @@ Because the name "User" can be misleading, we recommend reassigning any team mem
- **Owner** -- Full control over the team and organization
- **Admin** -- Full access with limited team management
-- **Editor** -- Can create and modify paywalls, campaigns, and products
+- **Editor** -- Can create and modify paywalls, campaigns, notifications, and assets
- **Reader** -- View-only access
+- **Analyst** -- Analytics-focused read-only access
The User role grants Admin-level permissions, not restricted permissions. If you have team members assigned the User role, review their access and reassign them to the appropriate role.
@@ -23,6 +24,8 @@ The User role grants Admin-level permissions, not restricted permissions. If you
1. Go to **Settings** in the Superwall dashboard
2. Select the **Team** section
3. Find the team member whose role you want to change
-4. Update their role to Owner, Admin, Editor, or Reader
+4. Update their role to Owner, Admin, Editor, Reader, or Analyst
Only **Owners** and **Admins** can change team member roles.
+
+To restrict a member to specific projects, use [Access Controls](/dashboard/dashboard-settings/overview-settings-access-controls).
diff --git a/content/shared/advanced-configuration.mdx b/content/shared/advanced-configuration.mdx
index a4bea905..79a6b513 100644
--- a/content/shared/advanced-configuration.mdx
+++ b/content/shared/advanced-configuration.mdx
@@ -16,7 +16,7 @@ By default, Superwall handles basic subscription-related logic for you:
However, if you want more control, you can pass in a `PurchaseController` when configuring the SDK via `configure(apiKey:purchaseController:options:)` and manually set `Superwall.shared.subscriptionStatus` to take over this responsibility.
-Starting in `4.15.0`, a `PurchaseController` is also how you handle custom products attached to Superwall paywalls. Those products are not purchased with StoreKit, so your controller must route them through your own billing system.
+On iOS, starting in `4.15.0`, a `PurchaseController` is also how you handle custom products attached to Superwall paywalls. Those products are not purchased with StoreKit, so your controller must route them through your own billing system. See [Custom Store Products](/ios/guides/custom-store-products) for the full iOS setup.
### Step 1: Creating a `PurchaseController`