Repository: RevenueCat/purchases-ios
Pull request #4979: [DX-345] Update the `testSDKHealth` to use the new `/health_report` endpoint
Author: login: polpielladev
Labels: name: pr:other
Created: 2025-04-10T14:00:11Z | Merged: 2025-04-24T14:05:07Z

### Checklist
- [ ] If applicable, unit tests
- [ ] If applicable, create follow-up issues for `purchases-android` and hybrids

### Motivation

To guide RevenueCat users better while configuring the SDK, we would like to modify the `testSDKHealth` endpoint to use the new `/health_report` endpoint and, at the same time, improve the `Status` section of the Debug View to include better and more in-depth information about why the configuration is not valid.

https://github.com/user-attachments/assets/2e1de5a4-7d8f-4f7a-afcb-5fe2dccd674c

### Description

The new method and view do the following validations:
- Check if the API key is valid.
- Check if the SDK version is supported.
- Check if the Bundle ID set in the app matches the one configured in the RevenueCat dashboard.
- Check if there is at least one product that is 'OK' in App Store Connect. If not, we fail. We also log warnings for any other products that might not be okay.
- If there are no offerings we log a warning (offerings are optional but recommended). This will serve as a bit of education to let people know that they need offerings to make use of Paywalls.
- If there are offerings and the default offering has no packages, we fail.
- If there are offerings and there are no valid products in the default offering, we fail.
- If the default offering is okay but we spot issues in any other ones, we log a warning and we display it in the details view. 



Diff:
diff --git a/RevenueCat.xcodeproj/project.pbxproj b/RevenueCat.xcodeproj/project.pbxproj
index 664bff98c9..ea7a5c409f 100644
--- a/RevenueCat.xcodeproj/project.pbxproj
+++ b/RevenueCat.xcodeproj/project.pbxproj
@@ -542,6 +542,12 @@
 		4FFFE6C82AA9467800B2955C /* PaywallEventsManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FFFE6C72AA9467800B2955C /* PaywallEventsManagerTests.swift */; };
 		4FFFE6CA2AA946A700B2955C /* MockInternalAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FFFE6C92AA946A700B2955C /* MockInternalAPI.swift */; };
 		4FFFE6E72AA948A600B2955C /* PaywallEventsIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FFFE6E62AA948A600B2955C /* PaywallEventsIntegrationTests.swift */; };
+		537B4B2E2DA9662700CEFF4C /* HealthReportResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 537B4B2D2DA9662700CEFF4C /* HealthReportResponse.swift */; };
+		537B4B302DA9742500CEFF4C /* HealthReportOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 537B4B2F2DA9742500CEFF4C /* HealthReportOperation.swift */; };
+		537B4B322DA9743800CEFF4C /* HealthReport+Validate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 537B4B312DA9743800CEFF4C /* HealthReport+Validate.swift */; };
+		537B4B382DA9744B00CEFF4C /* SDKHealthStatus+Icon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 537B4B362DA9744B00CEFF4C /* SDKHealthStatus+Icon.swift */; };
+		537B4B392DA9744B00CEFF4C /* SDKHealthCheckStatus+Icon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 537B4B352DA9744B00CEFF4C /* SDKHealthCheckStatus+Icon.swift */; };
+		537B4B3A2DA9744B00CEFF4C /* ProductStatus+Icon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 537B4B342DA9744B00CEFF4C /* ProductStatus+Icon.swift */; };
 		57045B3829C514A8001A5417 /* ProductEntitlementMappingDecodingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57045B3729C514A8001A5417 /* ProductEntitlementMappingDecodingTests.swift */; };
 		57045B3A29C51751001A5417 /* GetProductEntitlementMappingOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57045B3929C51751001A5417 /* GetProductEntitlementMappingOperation.swift */; };
 		57045B3C29C51AF7001A5417 /* ProductEntitlementMappingCallback.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57045B3B29C51AF7001A5417 /* ProductEntitlementMappingCallback.swift */; };
@@ -1901,6 +1907,12 @@
 		4FFFE6C72AA9467800B2955C /* PaywallEventsManagerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PaywallEventsManagerTests.swift; sourceTree = "<group>"; };
 		4FFFE6C92AA946A700B2955C /* MockInternalAPI.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockInternalAPI.swift; sourceTree = "<group>"; };
 		4FFFE6E62AA948A600B2955C /* PaywallEventsIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallEventsIntegrationTests.swift; sourceTree = "<group>"; };
+		537B4B2D2DA9662700CEFF4C /* HealthReportResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthReportResponse.swift; sourceTree = "<group>"; };
+		537B4B2F2DA9742500CEFF4C /* HealthReportOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthReportOperation.swift; sourceTree = "<group>"; };
+		537B4B312DA9743800CEFF4C /* HealthReport+Validate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HealthReport+Validate.swift"; sourceTree = "<group>"; };
+		537B4B342DA9744B00CEFF4C /* ProductStatus+Icon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProductStatus+Icon.swift"; sourceTree = "<group>"; };
+		537B4B352DA9744B00CEFF4C /* SDKHealthCheckStatus+Icon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SDKHealthCheckStatus+Icon.swift"; sourceTree = "<group>"; };
+		537B4B362DA9744B00CEFF4C /* SDKHealthStatus+Icon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SDKHealthStatus+Icon.swift"; sourceTree = "<group>"; };
 		57045B3729C514A8001A5417 /* ProductEntitlementMappingDecodingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductEntitlementMappingDecodingTests.swift; sourceTree = "<group>"; };
 		57045B3929C51751001A5417 /* GetProductEntitlementMappingOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetProductEntitlementMappingOperation.swift; sourceTree = "<group>"; };
 		57045B3B29C51AF7001A5417 /* ProductEntitlementMappingCallback.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductEntitlementMappingCallback.swift; sourceTree = "<group>"; };
@@ -4086,6 +4098,7 @@
 				4FE6669E2A2F95A1004EEAFC /* PaywallExtensions.swift */,
 				1E473B652AC42D34008B07F9 /* StoreMessagesHelper.swift */,
 				1E473B672AC43254008B07F9 /* StoreMessageType.swift */,
+				537B4B312DA9743800CEFF4C /* HealthReport+Validate.swift */,
 			);
 			path = Support;
 			sourceTree = "<group>";
@@ -4256,6 +4269,9 @@
 				4F6BEDDF2A26B65900CD9322 /* DebugViewSheetPresentation.swift */,
 				4F6BEDE12A26B69500CD9322 /* DebugContentViews.swift */,
 				4FCEEA5D2A379B80002C2112 /* DebugViewController.swift */,
+				537B4B342DA9744B00CEFF4C /* ProductStatus+Icon.swift */,
+				537B4B352DA9744B00CEFF4C /* SDKHealthCheckStatus+Icon.swift */,
+				537B4B362DA9744B00CEFF4C /* SDKHealthStatus+Icon.swift */,
 			);
 			path = DebugUI;
 			sourceTree = "<group>";
@@ -4596,6 +4612,7 @@
 				57D5412D27F6311C004CC35C /* OfferingsResponse.swift */,
 				574A2F4A282D7AEA00150D40 /* PostOfferResponse.swift */,
 				57488A7E29CA145B0000EE7E /* ProductEntitlementMappingResponse.swift */,
+				537B4B2D2DA9662700CEFF4C /* HealthReportResponse.swift */,
 			);
 			path = Responses;
 			sourceTree = "<group>";
@@ -5182,6 +5199,7 @@
 				B34605BA279A6E380031CA74 /* GetOfferingsOperation.swift */,
 				57045B3929C51751001A5417 /* GetProductEntitlementMappingOperation.swift */,
 				4F5F47072AA91F8A005649D8 /* HealthOperation.swift */,
+				537B4B2F2DA9742500CEFF4C /* HealthReportOperation.swift */,
 				B34605B7279A6E380031CA74 /* LogInOperation.swift */,
 				B34605AB279A6E380031CA74 /* NetworkOperation.swift */,
 				B34605AD279A6E380031CA74 /* PostOfferForSigningOperation.swift */,
@@ -6184,6 +6202,7 @@
 				57EAE52B274332830060EB74 /* Obsoletions.swift in Sources */,
 				57E9CF0F290B2A4B00EE12D1 /* CachingTrialOrIntroPriceEligibilityChecker.swift in Sources */,
 				2C0B98CD2797070B00C5874F /* PromotionalOffer.swift in Sources */,
+				537B4B2E2DA9662700CEFF4C /* HealthReportResponse.swift in Sources */,
 				57DC9F4627CC2E4900DA6AF9 /* HTTPRequest.swift in Sources */,
 				2DC5623024EC63730031F69B /* OperationDispatcher.swift in Sources */,
 				352137322CDBA2AA00FE961B /* FeatureEvent.swift in Sources */,
@@ -6204,6 +6223,7 @@
 				57488C7729CB90F90000EE7E /* PurchasedProductsFetcher.swift in Sources */,
 				4F3C986A2A44FA60009AECA3 /* ErrorResponse.swift in Sources */,
 				578C5F2C28DB82DD00A56F02 /* PurchasesDiagnostics.swift in Sources */,
+				537B4B322DA9743800CEFF4C /* HealthReport+Validate.swift in Sources */,
 				B34605C9279A6E380031CA74 /* GetIntroEligibilityOperation.swift in Sources */,
 				354895D6267BEDE3001DC5B1 /* ReservedSubscriberAttributes.swift in Sources */,
 				570FAF4B2864EC2300D3C769 /* NonSubscriptionTransaction.swift in Sources */,
@@ -6229,6 +6249,7 @@
 				03C730F32D35F14600297FEC /* PaywallV2CacheWarming.swift in Sources */,
 				B3C4AAD526B8911300E1B3C8 /* Backend.swift in Sources */,
 				35AAEB452BBB14D000A12548 /* DiagnosticsFileHandler.swift in Sources */,
+				537B4B302DA9742500CEFF4C /* HealthReportOperation.swift in Sources */,
 				B34D2AA626976FC700D88C3A /* ErrorCode.swift in Sources */,
 				4F15B4A12A6774C9005BEFE8 /* CustomerInfo+NonSubscriptions.swift in Sources */,
 				887E7B592C13CD2C002977DE /* PurchasesAreCompletedBy.swift in Sources */,
@@ -6256,6 +6277,9 @@
 				2C7F0AD32B8EEB4600381179 /* RateLimiter.swift in Sources */,
 				B3A55B7D26C452A7007EFC56 /* AttributionPoster.swift in Sources */,
 				4F8452682A5756CC00084550 /* HTTPRequestBody+Signing.swift in Sources */,
+				537B4B382DA9744B00CEFF4C /* SDKHealthStatus+Icon.swift in Sources */,
+				537B4B392DA9744B00CEFF4C /* SDKHealthCheckStatus+Icon.swift in Sources */,
+				537B4B3A2DA9744B00CEFF4C /* ProductStatus+Icon.swift in Sources */,
 				5791FE4A2994453500F1FEDA /* Signing+ResponseVerification.swift in Sources */,
 				2DC19195255F36D10039389A /* Logger.swift in Sources */,
 				2DDF419F24F6F331005BC22D /* ReceiptParsingError.swift in Sources */,
diff --git a/Sources/Networking/Backend.swift b/Sources/Networking/Backend.swift
index 49c3e69779..7685c2e28a 100644
--- a/Sources/Networking/Backend.swift
+++ b/Sources/Networking/Backend.swift
@@ -158,6 +158,19 @@ extension Backend {
 
 }
 
+extension Backend {
+
+    /// Call the `/health_report` endpoint and perform a full validation of the SDK's configuration
+    /// - Parameter appUserID: An `appUserID` that allows the Backend to fetch offerings
+    /// - Returns: A report with all validation checks along with their status
+    func healthReportRequest(appUserID: String) async throws -> HealthReport {
+        try await Async.call { (completion: @escaping (Result<HealthReport, BackendError>) -> Void) in
+            self.internalAPI.healthReportRequest(appUserID: appUserID, completion: completion)
+        }
+    }
+
+}
+
 // @unchecked because:
 // - Class is not `final` (it's mocked). This implicitly makes subclasses `Sendable` even if they're not thread-safe.
 extension Backend: @unchecked Sendable {}
diff --git a/Sources/Networking/HTTPClient/HTTPRequestPath.swift b/Sources/Networking/HTTPClient/HTTPRequestPath.swift
index bf6fe5ba12..0517e16378 100644
--- a/Sources/Networking/HTTPClient/HTTPRequestPath.swift
+++ b/Sources/Networking/HTTPClient/HTTPRequestPath.swift
@@ -69,6 +69,7 @@ extension HTTPRequest {
         case postSubscriberAttributes(appUserID: String)
         case postAdServicesToken(appUserID: String)
         case health
+        case appHealthReport(appUserID: String)
         case getProductEntitlementMapping
         case getCustomerCenterConfig(appUserID: String)
         case postRedeemWebPurchase
@@ -107,7 +108,8 @@ extension HTTPRequest.Path: HTTPRequestPath {
                 .postAdServicesToken,
                 .postRedeemWebPurchase,
                 .getProductEntitlementMapping,
-                .getCustomerCenterConfig:
+                .getCustomerCenterConfig,
+                .appHealthReport:
             return true
 
         case .health:
@@ -128,7 +130,8 @@ extension HTTPRequest.Path: HTTPRequestPath {
                 .postAdServicesToken,
                 .postRedeemWebPurchase,
                 .getProductEntitlementMapping,
-                .getCustomerCenterConfig:
+                .getCustomerCenterConfig,
+                .appHealthReport:
             return true
         case .health:
             return false
@@ -142,7 +145,8 @@ extension HTTPRequest.Path: HTTPRequestPath {
                 .postReceiptData,
                 .health,
                 .getOfferings,
-                .getProductEntitlementMapping:
+                .getProductEntitlementMapping,
+                .appHealthReport:
             return true
         case .getIntroEligibility,
                 .postSubscriberAttributes,
@@ -170,7 +174,8 @@ extension HTTPRequest.Path: HTTPRequestPath {
                 .postOfferForSigning,
                 .postRedeemWebPurchase,
                 .getProductEntitlementMapping,
-                .getCustomerCenterConfig:
+                .getCustomerCenterConfig,
+                .appHealthReport:
             return false
         }
     }
@@ -186,6 +191,9 @@ extension HTTPRequest.Path: HTTPRequestPath {
         case let .getIntroEligibility(appUserID):
             return "subscribers/\(Self.escape(appUserID))/intro_eligibility"
 
+        case let .appHealthReport(appUserID):
+            return "subscribers/\(Self.escape(appUserID))/health_report"
+
         case .logIn:
             return "subscribers/identify"
 
@@ -260,6 +268,9 @@ extension HTTPRequest.Path: HTTPRequestPath {
         case .postRedeemWebPurchase:
             return "post_redeem_web_purchase"
 
+        case .appHealthReport:
+            return "get_app_health_report"
+
         }
     }
 
diff --git a/Sources/Networking/InternalAPI.swift b/Sources/Networking/InternalAPI.swift
index c13d230805..ef64f9442a 100644
--- a/Sources/Networking/InternalAPI.swift
+++ b/Sources/Networking/InternalAPI.swift
@@ -16,13 +16,16 @@ import Foundation
 class InternalAPI {
 
     typealias ResponseHandler = (BackendError?) -> Void
+    typealias HealthReportResponseHandler = (Result<HealthReport, BackendError>) -> Void
 
     private let backendConfig: BackendConfiguration
     private let healthCallbackCache: CallbackCache<HealthOperation.Callback>
+    private let healthReportCallbackCache: CallbackCache<HealthReportOperation.Callback>
 
     init(backendConfig: BackendConfiguration) {
         self.backendConfig = backendConfig
         self.healthCallbackCache = .init()
+        self.healthReportCallbackCache = .init()
     }
 
     func healthRequest(signatureVerification: Bool, completion: @escaping ResponseHandler) {
@@ -38,6 +41,19 @@ class InternalAPI {
                                                  cacheStatus: cacheStatus)
     }
 
+    func healthReportRequest(appUserID: String, completion: @escaping HealthReportResponseHandler) {
+        let config = NetworkOperation.UserSpecificConfiguration(httpClient: self.backendConfig.httpClient,
+                                                                appUserID: appUserID)
+        let factory = HealthReportOperation.createFactory(configuration: config,
+                                                          callbackCache: self.healthReportCallbackCache)
+        let callback = HealthReportOperation.Callback(cacheKey: factory.cacheKey, completion: completion)
+        let cacheStatus = self.healthReportCallbackCache.add(callback)
+
+        self.backendConfig.addCacheableOperation(with: factory,
+                                                 delay: .none,
+                                                 cacheStatus: cacheStatus)
+    }
+
     @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
     func postPaywallEvents(events: [StoredEvent], completion: @escaping ResponseHandler) {
         guard !events.isEmpty else {
diff --git a/Sources/Networking/Operations/HealthReportOperation.swift b/Sources/Networking/Operations/HealthReportOperation.swift
new file mode 100644
index 0000000000..e41ee8c41a
--- /dev/null
+++ b/Sources/Networking/Operations/HealthReportOperation.swift
@@ -0,0 +1,73 @@
+//
+//  Copyright RevenueCat Inc. All Rights Reserved.
+//
+//  Licensed under the MIT License (the "License");
+//  you may not use this file except in compliance with the License.
+//  You may obtain a copy of the License at
+//
+//      https://opensource.org/licenses/MIT
+//
+//  HealthReportOperation.swift
+//
+//  Created by Pol Piella on 4/8/25.
+
+import Foundation
+
+final class HealthReportOperation: CacheableNetworkOperation {
+
+    struct Callback: CacheKeyProviding {
+
+        let cacheKey: String
+        let completion: InternalAPI.HealthReportResponseHandler
+
+    }
+
+    private let configuration: AppUserConfiguration
+    private let callbackCache: CallbackCache<Callback>
+
+    static func createFactory(
+        configuration: UserSpecificConfiguration,
+        callbackCache: CallbackCache<Callback>
+    ) -> CacheableNetworkOperationFactory<HealthReportOperation> {
+        return .init({ .init(configuration: configuration,
+                             callbackCache: callbackCache,
+                             cacheKey: $0) },
+                     individualizedCacheKeyPart: "")
+    }
+
+    private init(configuration: UserSpecificConfiguration,
+                 callbackCache: CallbackCache<Callback>,
+                 cacheKey: String) {
+        self.configuration = configuration
+        self.callbackCache = callbackCache
+
+        super.init(configuration: configuration, cacheKey: cacheKey)
+    }
+
+    override func begin(completion: @escaping () -> Void) {
+        let request: HTTPRequest = .init(method: .get,
+                                         path: .appHealthReport(appUserID: configuration.appUserID))
+
+        self.httpClient.perform(
+            request
+        ) { (response: VerifiedHTTPResponse<HealthReport>.Result) in
+            self.finish(with: response, completion: completion)
+        }
+    }
+
+    private func finish(with response: VerifiedHTTPResponse<HealthReport>.Result,
+                        completion: () -> Void) {
+        self.callbackCache.performOnAllItemsAndRemoveFromCache(withCacheable: self) { callback in
+            callback.completion(
+                response
+                    .mapError(BackendError.networkError)
+                    .map { $0.body }
+            )
+        }
+
+        completion()
+    }
+}
+
+// Restating inherited @unchecked Sendable from Foundation's Operation
+extension HealthReportOperation: @unchecked Sendable {}
diff --git a/Sources/Networking/Responses/HealthReportResponse.swift b/Sources/Networking/Responses/HealthReportResponse.swift
new file mode 100644
index 0000000000..dfa8fff7b8
--- /dev/null
+++ b/Sources/Networking/Responses/HealthReportResponse.swift
@@ -0,0 +1,140 @@
+//
+//  Copyright RevenueCat Inc. All Rights Reserved.
+//
+//  Licensed under the MIT License (the "License");
+//  you may not use this file except in compliance with the License.
+//  You may obtain a copy of the License at
+//
+//      https://opensource.org/licenses/MIT
+//
+//  HealthReportResponse.swift
+//
+//  Created by Pol Piella on 4/8/25.
+
+import Foundation
+
+enum HealthCheckStatus: String {
+    case passed
+    case failed
+    case warning
+    case unknown
+}
+
+enum HealthCheckType: String {
+    case apiKey = "api_key"
+    case bundleId = "bundle_id"
+    case products = "products"
+    case offerings = "offerings"
+    case offeringsProducts = "offerings_products"
+}
+
+enum ProductStatus: String {
+    case valid = "ok"
+    case couldNotCheck = "could_not_check"
+    case notFound = "not_found"
+    case actionInProgress = "action_in_progress"
+    case needsAction = "needs_action"
+    case unknown
+}
+
+struct PackageHealthReport {
+    let identifier: String
+    let title: String?
+    let status: ProductStatus
+    let description: String
+    let productIdentifier: String
+    let productTitle: String?
+}
+
+struct OfferingHealthReport {
+    let identifier: String
+    let packages: [PackageHealthReport]
+    let status: HealthCheckStatus
+}
+
+struct OfferingsCheckDetails {
+    let offerings: [OfferingHealthReport]
+}
+
+struct BundleIdCheckDetails {
+    let sdkBundleId: String
+    let appBundleId: String
+}
+
+enum HealthCheckDetails {
+    case offeringsProducts(OfferingsCheckDetails)
+    case bundleId(BundleIdCheckDetails)
+    case products(ProductsCheckDetails)
+}
+
+struct ProductsCheckDetails {
+    let products: [ProductHealthReport]
+}
+
+struct ProductHealthReport {
+    let identifier: String
+    let title: String?
+    let status: ProductStatus
+    let description: String
+}
+
+struct HealthCheck {
+    let name: HealthCheckType
+    let status: HealthCheckStatus
+    let details: HealthCheckDetails?
+
+    enum CodingKeys: String, CodingKey {
+        case name
+        case status
+        case details
+    }
+
+    init(name: HealthCheckType, status: HealthCheckStatus, details: HealthCheckDetails? = nil) {
+        self.name = name
+        self.status = status
+        self.details = details
+    }
+
+    init(from decoder: Decoder) throws {
+        let container = try decoder.container(keyedBy: CodingKeys.self)
+        name = try container.decode(HealthCheckType.self, forKey: .name)
+        status = try container.decode(HealthCheckStatus.self, forKey: .status)
+
+        switch name {
+        case .offeringsProducts:
+            details = (try container.decodeIfPresent(OfferingsCheckDetails.self, forKey: .details))
+                .map({ .offeringsProducts($0) })
+        case .bundleId:
+            details = (try container.decodeIfPresent(BundleIdCheckDetails.self, forKey: .details))
+                .map({ .bundleId($0) })
+
+        case .products:
+            details = (try container.decodeIfPresent(ProductsCheckDetails.self, forKey: .details))
+                .map({ .products($0) })
+
+        default:
+            details = nil
+        }
+    }
+}
+
+struct HealthReport {
+    let status: HealthCheckStatus
+    let projectId: String?
+    let appId: String?
+    let checks: [HealthCheck]
+}
+
+extension HealthReport: HTTPResponseBody {}
+extension HealthReport: Codable, Equatable {}
+extension HealthCheck: Codable, Equatable {}
+extension HealthCheckDetails: Codable, Equatable {}
+extension HealthCheckType: Codable, Equatable {}
+extension HealthCheckStatus: Codable, Equatable {}
+extension OfferingsCheckDetails: Codable, Equatable {}
+extension BundleIdCheckDetails: Codable, Equatable {}
+extension ProductsCheckDetails: Codable, Equatable {}
+extension ProductStatus: Codable, Equatable {}
+extension ProductHealthReport: Codable, Equatable {}
+extension OfferingHealthReport: Codable, Equatable {}
+extension PackageHealthReport: Codable, Equatable {}
diff --git a/Sources/Purchasing/Purchases/Purchases.swift b/Sources/Purchasing/Purchases/Purchases.swift
index a88e890cce..6144b6a3c3 100644
--- a/Sources/Purchasing/Purchases/Purchases.swift
+++ b/Sources/Purchasing/Purchases/Purchases.swift
@@ -1777,6 +1777,10 @@ extension Purchases: InternalPurchasesType {
         }
     }
 
+    internal func healthReportRequest() async throws -> HealthReport {
+        try await self.backend.healthReportRequest(appUserID: self.appUserID)
+    }
+
     @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *)
     func productEntitlementMapping() async throws -> ProductEntitlementMapping {
         let response = try await Async.call { completion in
diff --git a/Sources/Purchasing/Purchases/PurchasesType.swift b/Sources/Purchasing/Purchases/PurchasesType.swift
index da783c25af..b5d873677a 100644
--- a/Sources/Purchasing/Purchases/PurchasesType.swift
+++ b/Sources/Purchasing/Purchases/PurchasesType.swift
@@ -1246,6 +1246,11 @@ internal protocol InternalPurchasesType: AnyObject {
     /// - Throws: `PublicError` if request failed.
     func healthRequest(signatureVerification: Bool) async throws
 
+    /// Requests an in-depth report of the SDK's configuration from the server.
+    /// - Throws: A `BackendError` if the request fails due to an invalid API key or connectivity issues.
+    /// - Returns: A health report containing all checks performed on the server and their status.
+    func healthReportRequest() async throws -> HealthReport
+
     func offerings(fetchPolicy: OfferingsManager.FetchPolicy) async throws -> Offerings
 
     @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *)
diff --git a/Sources/Support/DebugUI/DebugContentViews.swift b/Sources/Support/DebugUI/DebugContentViews.swift
index eb9bd28607..6bca450f32 100644
--- a/Sources/Support/DebugUI/DebugContentViews.swift
+++ b/Sources/Support/DebugUI/DebugContentViews.swift
@@ -47,6 +47,9 @@ struct DebugSwiftUIRootView: View {
 
                     case let .paywall(paywall):
                         DebugPaywallJSONView(paywall: paywall)
+
+                    case .statusDetails:
+                        DebugStatusDetailsView(errors: model.errorsToExpandOn)
                     }
                 }
                 .background(
@@ -81,6 +84,7 @@ private enum DebugViewPath: Hashable {
     case offeringMetadata(Offering)
     case package(Package)
     case paywall(PaywallData)
+    case statusDetails
 
 }
 
@@ -94,6 +98,8 @@ internal struct DebugSummaryView: View {
         List {
             self.diagnosticsSection
 
+            self.openAppButton
+
             self.configurationSection
 
             #if !ENABLE_CUSTOM_ENTITLEMENT_COMPUTATION
@@ -109,17 +115,46 @@ internal struct DebugSummaryView: View {
         .navigationTitle("RevenueCat Debug")
     }
 
+    private var statusLabel: some View {
+        LabeledContent("Status") {
+            HStack {
+                Text(self.model.diagnosticsStatus)
+                self.model.diagnosticsIcon
+            }
+        }
+    }
+
     private var diagnosticsSection: some View {
-        Section("Diagnostics") {
-            LabeledContent("Status") {
-                HStack {
-                    Text(self.model.diagnosticsStatus)
-                    self.model.diagnosticsIcon
+        Section {
+            if !self.model.errorsToExpandOn.isEmpty {
+                NavigationLink(value: DebugViewPath.statusDetails) {
+                    statusLabel
                 }
+            } else {
+                statusLabel
+            }
+
+        } header: {
+            Text("Diagnostics")
+        } footer: {
+            if let diagnosticsFooter = self.model.diagnosticsExplainer {
+                Text(diagnosticsFooter)
             }
         }
     }
 
+    private var openAppButton: some View {
+        Group {
+            if let url = model.diagnosticsActionURL,
+                let title = model.diagnosticsActionTitle {
+                Link(destination: url) {
+                    Label(title, systemImage: "arrow.up.forward")
+                }
+            }
+        }
+
+    }
+
     private var configurationSection: some View {
         Section("Configuration") {
             switch self.model.configuration {
@@ -414,6 +449,78 @@ private struct DebugPackageView: View {
 
 }
 
+@available(iOS 16.0, macOS 13.0, *)
+private struct DebugStatusDetailsView: View {
+    let errors: [PurchasesDiagnostics.Error]
+
+    var body: some View {
+        List {
+            ForEach(errors, id: \.errorCode) { error in
+                switch error {
+                case let .invalidProducts(products):
+                    Section("Product Validation Issues") {
+                        ForEach(products, id: \.identifier) { product in
+                            if product.status != .valid {
+                                VStack(alignment: .leading, spacing: 8) {
+                                    HStack(alignment: .center, spacing: 8) {
+                                        product.status.icon.imageScale(.small)
+                                        Text(product.identifier)
+                                    }
+                                    Text(product.description)
+                                        .foregroundStyle(.secondary)
+                                        .font(.caption)
+                                }
+                            }
+                        }
+                    }
+                case .noOfferings:
+                    Section("Offering Validation Issues") {
+                        VStack(alignment: .leading, spacing: 8) {
+                            HStack(alignment: .center, spacing: 8) {
+                                Image(systemName: "exclamationmark.triangle.fill")
+                                    .foregroundColor(.yellow)
+                                Text("No offerings configured")
+                            }
+                            Text(
+                                """
+                                While offerings are not mandatory, they are the way to 'offer' products \
+                                to your customers on your paywalls.
+                                """
+                            )
+                            .font(.caption)
+                            .foregroundStyle(.secondary)
+                        }
+                    }
+                case let .offeringConfiguration(offerings):
+                    Section("Offering Validation Issues") {
+                        ForEach(offerings, id: \.identifier) { offering in
+                            if offering.status != .passed {
+                                VStack(alignment: .leading, spacing: 8) {
+                                    HStack(alignment: .center, spacing: 8) {
+                                        offering.status.icon.imageScale(.small)
+                                        Text(offering.identifier)
+                                    }
+                                    Text(
+                                        offering.packages.isEmpty ?
+                                        "Offerings must have at least one package" :
+                                        """
+                                        Products \(offering.packages.map { "'\($0.productIdentifier)'" }.formatted()) \
+                                        in the offering have issues, check the products list above for more details.
+                                        """
+                                    )
+                                    .font(.caption)
+                                    .foregroundStyle(.secondary)
+                                }
+                            }
+                        }
+                    }
+                default: EmptyView()
+                }
+            }
+        }
+    }
+}
+
 @available(iOS 16.0, macOS 13.0, *)
 private struct DebugPaywallJSONView: View {
 
diff --git a/Sources/Support/DebugUI/DebugViewModel.swift b/Sources/Support/DebugUI/DebugViewModel.swift
index 2ea0f3f51f..37c8cab059 100644
--- a/Sources/Support/DebugUI/DebugViewModel.swift
+++ b/Sources/Support/DebugUI/DebugViewModel.swift
@@ -37,7 +37,7 @@ final class DebugViewModel: ObservableObject {
     var configuration: LoadingState<Configuration, Never> = .loading
 
     @Published
-    var diagnosticsResult: LoadingState<(), NSError> = .loading
+    var diagnosticsResult: LoadingState<PurchasesDiagnostics.SDKHealthReport, Never> = .loading
     @Published
     var offerings: LoadingState<Offerings, NSError> = .loading
     #if !ENABLE_CUSTOM_ENTITLEMENT_COMPUTATION
@@ -63,7 +63,7 @@ final class DebugViewModel: ObservableObject {
     func load() async {
         self.configuration = .loaded(.create())
 
-        self.diagnosticsResult = await .create { try await PurchasesDiagnostics.default.testSDKHealth() }
+        self.diagnosticsResult = .loaded(await PurchasesDiagnostics.default.healthReport())
         self.offerings = await .create { try await Purchases.shared.offerings() }
         #if !ENABLE_CUSTOM_ENTITLEMENT_COMPUTATION
         self.customerInfo = await .create { try await Purchases.shared.customerInfo() }
@@ -83,8 +83,70 @@ extension DebugViewModel {
     var diagnosticsStatus: String {
         switch self.diagnosticsResult {
         case .loading: return "Loading..."
-        case .loaded: return "Configuration OK"
-        case let .failed(error): return "Error: \(error.localizedDescription)"
+        case let .loaded(healthReport):
+            switch healthReport.status {
+            case .healthy: return "Configuration OK"
+            case .unhealthy: return "Invalid Configuration"
+            }
+        }
+    }
+
+    var diagnosticsExplainer: String? {
+        switch self.diagnosticsResult {
+        case let .loaded(healthReport):
+            switch healthReport.status {
+            case let .healthy(warnings):
+                return warnings.count > 0 ? """
+                Your RevenueCat configuration is valid, however we encountered some potential issues \
+                during validation. Feel free to ignore them if your configuration works as expected.
+                """
+                : nil
+            case .unhealthy(let error): return error.localizedDescription
+            }
+        default: return nil
+        }
+    }
+
+    var diagnosticsActionURL: URL? {
+        switch self.diagnosticsResult {
+        case let .loaded(healthReport):
+            guard let appId = healthReport.appId, let projectId = healthReport.projectId else {
+                return nil
+            }
+            switch healthReport.status {
+            case .healthy: return nil
+            case .unhealthy(let error):
+                switch error {
+                case .offeringConfiguration:
+                    return URL(string: "https://app.revenuecat.com/projects/\(projectId)/offerings")
+                case .invalidBundleId:
+                    return URL(string: "https://app.revenuecat.com/projects/\(projectId)/apps/\(appId)")
+                case .invalidProducts:
+                    return URL(string: "https://app.revenuecat.com/projects/\(projectId)/products")
+                default: return nil
+                }
+            }
+        case .loading, .failed: return nil
+        }
+    }
+
+    var diagnosticsActionTitle: String? {
+        switch self.diagnosticsResult {
+        case .loading, .failed: return nil
+        case let .loaded(healthReport):
+            switch healthReport.status {
+            case .healthy: return nil
+            case .unhealthy(let error):
+                switch error {
+                case .offeringConfiguration:
+                    return "Open Offerings"
+                case .invalidBundleId:
+                    return "Open App Configuration"
+                case .invalidProducts:
+                    return "Open Products"
+                default: return nil
+                }
+            }
         }
     }
 
@@ -94,15 +156,25 @@ extension DebugViewModel {
         case .loading:
             Image(systemName: "gear.circle")
                 .foregroundColor(.gray)
-        case .loaded:
-            Image(systemName: "checkmark.circle.fill")
-                .foregroundColor(.green)
-        case .failed:
-            Image(systemName: "exclamationmark.triangle")
-                .foregroundColor(.red)
+        case let .loaded(healthReport):
+            healthReport.status.icon
         }
     }
 
+    var errorsToExpandOn: [PurchasesDiagnostics.Error] {
+        switch self.diagnosticsResult {
+        case .loading: return []
+        case let .loaded(healthReport):
+            switch healthReport.status {
+            case let .healthy(warnings): return warnings
+            case let .unhealthy(error):
+                switch error {
+                case .invalidProducts, .offeringConfiguration: return [error]
+                default: return []
+                }
+            }
+        }
+    }
 }
 
 @available(iOS 16.0, macOS 13.0, *)
diff --git a/Sources/Support/DebugUI/ProductStatus+Icon.swift b/Sources/Support/DebugUI/ProductStatus+Icon.swift
new file mode 100644
index 0000000000..8c61217c08
--- /dev/null
+++ b/Sources/Support/DebugUI/ProductStatus+Icon.swift
@@ -0,0 +1,34 @@
+//
+//  Copyright RevenueCat Inc. All Rights Reserved.
+//
+//  Licensed under the MIT License (the "License");
+//  you may not use this file except in compliance with the License.
+//  You may obtain a copy of the License at
+//
+//      https://opensource.org/licenses/MIT
+//
+//  ProductStatus+Icon.swift
+//
+//  Created by Pol Piella on 4/10/25.
+
+import SwiftUI
+
+@available(iOS 16.0, macOS 13.0, *)
+extension PurchasesDiagnostics.ProductStatus {
+    var icon: some View {
+        switch self {
+        case .valid:
+            Image(systemName: "checkmark.circle.fill")
+                .foregroundColor(.green)
+        case .couldNotCheck, .unknown:
+            Image(systemName: "questionmark.circle.fill")
+                .foregroundColor(.gray)
+        case .notFound:
+            Image(systemName: "xmark.circle.fill")
+                .foregroundColor(.red)
+        case .actionInProgress, .needsAction:
+            Image(systemName: "exclamationmark.triangle.fill")
+                .foregroundColor(.yellow)
+        }
+    }
+}
diff --git a/Sources/Support/DebugUI/SDKHealthCheckStatus+Icon.swift b/Sources/Support/DebugUI/SDKHealthCheckStatus+Icon.swift
new file mode 100644
index 0000000000..9c4bd375da
--- /dev/null
+++ b/Sources/Support/DebugUI/SDKHealthCheckStatus+Icon.swift
@@ -0,0 +1,31 @@
+//
+//  Copyright RevenueCat Inc. All Rights Reserved.
+//
+//  Licensed under the MIT License (the "License");
+//  you may not use this file except in compliance with the License.
+//  You may obtain a copy of the License at
+//
+//      https://opensource.org/licenses/MIT
+//
+//  SDKHealthCheckStatus+Icon.swift
+//
+//  Created by Pol Piella on 4/10/25.
+
+import SwiftUI
+
+@available(iOS 16.0, macOS 13.0, *)
+extension PurchasesDiagnostics.SDKHealthCheckStatus {
+    var icon: some View {
+        switch self {
+        case .passed:
+            Image(systemName: "checkmark.circle.fill")
+                .foregroundColor(.green)
+        case .failed:
+            Image(systemName: "xmark.circle.fill")
+                .foregroundColor(.red)
+        case .warning:
+            Image(systemName: "exclamationmark.triangle.fill")
+                .foregroundColor(.yellow)
+        }
+    }
+}
diff --git a/Sources/Support/DebugUI/SDKHealthStatus+Icon.swift b/Sources/Support/DebugUI/SDKHealthStatus+Icon.swift
new file mode 100644
index 0000000000..b1ebfca2a7
--- /dev/null
+++ b/Sources/Support/DebugUI/SDKHealthStatus+Icon.swift
@@ -0,0 +1,29 @@
+//
+//  Copyright RevenueCat Inc. All Rights Reserved.
+//
+//  Licensed under the MIT License (the "License");
+//  you may not use this file except in compliance with the License.
+//  You may obtain a copy of the License at
+//
+//      https://opensource.org/licenses/MIT
+//
+//  SDKHealthStatus+Icon.swift
+//
+//  Created by Pol Piella on 4/10/25.
+
+import SwiftUI
+
+@available(iOS 16.0, macOS 13.0, *)
+extension PurchasesDiagnostics.SDKHealthStatus {
+    var icon: some View {
+        switch self {
+        case let .healthy(warnings):
+            Image(systemName: warnings.count > 0 ? "checkmark.circle.badge.questionmark.fill" : "checkmark.circle.fill")
+                .foregroundColor(.green)
+        case .unhealthy:
+            Image(systemName: "xmark.circle.fill")
+                .foregroundColor(.red)
+        }
+
+    }
+}
diff --git a/Sources/Support/HealthReport+Validate.swift b/Sources/Support/HealthReport+Validate.swift
new file mode 100644
index 0000000000..d19bbcae10
--- /dev/null
+++ b/Sources/Support/HealthReport+Validate.swift
@@ -0,0 +1,192 @@
+//
+//  Copyright RevenueCat Inc. All Rights Reserved.
+//
+//  Licensed under the MIT License (the "License");
+//  you may not use this file except in compliance with the License.
+//  You may obtain a copy of the License at
+//
+//      https://opensource.org/licenses/MIT
+//
+//  HealthReport+Validate.swift
+//
+//  Created by Pol Piella on 4/10/25.
+
+extension HealthReport {
+    func validate() -> PurchasesDiagnostics.SDKHealthReport {
+        guard let firstFailedCheck = self.checks.first(where: { $0.status == .failed }) else {
+            let warnings = self.checks.filter { $0.status == .warning }.map { error(from: $0) }
+            let products: [PurchasesDiagnostics.ProductDiagnosticsPayload] = {
+                guard case let .products(payload) = self
+                    .checks
+                    .first(where: { $0.name == .products })?.details else {
+                    return []
+                }
+
+                let productPayloads = payload.products.map(createProductPayload(from:))
+
+                return productPayloads
+            }()
+
+            let offerings: [PurchasesDiagnostics.OfferingDiagnosticsPayload] = {
+                guard case let .offeringsProducts(payload) = self
+                    .checks
+                    .first(where: { $0.name == .products })?.details else {
+                    return []
+                }
+
+                let offeringPayloads = payload.offerings.map { offeringCheck in
+                    let status = self.convertOfferingStatus(offeringCheck.status)
+                    return PurchasesDiagnostics.OfferingDiagnosticsPayload(
+                        identifier: offeringCheck.identifier,
+                        packages: offeringCheck.packages.map(createPackagePayload(from:)),
+                        status: status
+                    )
+                }
+
+                return offeringPayloads
+            }()
+
+            return .init(
+                status: .healthy(warnings: warnings),
+                projectId: self.projectId,
+                appId: self.appId,
+                products: products,
+                offerings: offerings
+            )
+        }
+
+        return .init(
+            status: .unhealthy(error(from: firstFailedCheck)),
+            projectId: self.projectId,
+            appId: self.appId
+        )
+    }
+
+    func error(from check: HealthCheck) -> PurchasesDiagnostics.Error {
+        switch check.name {
+        case .apiKey: return .invalidAPIKey
+        case .bundleId: return createBundleIdError(from: check)
+        case .products: return createProductsError(from: check)
+        case .offerings: return .noOfferings
+        case .offeringsProducts: return createOfferingsError(from: check)
+        }
+    }
+
+    private func createBundleIdError(from check: HealthCheck) -> PurchasesDiagnostics.Error {
+        guard case let .bundleId(payload) = check.details else {
+            return .invalidBundleId(nil)
+        }
+        return .invalidBundleId(.init(appBundleId: payload.appBundleId, sdkBundleId: payload.sdkBundleId))
+    }
+
+    private func createProductsError(from check: HealthCheck) -> PurchasesDiagnostics.Error {
+        guard case let .products(payload) = check.details else {
+            return .invalidProducts([])
+        }
+
+        return .invalidProducts(payload.products.map(createProductPayload))
+    }
+
+    private func createOfferingsError(from check: HealthCheck) -> PurchasesDiagnostics.Error {
+        guard case let .offeringsProducts(payload) = check.details else {
+            return .offeringConfiguration([])
+        }
+
+        let offeringPayloads = payload.offerings.map { offeringCheck in
+            let status = self.convertOfferingStatus(offeringCheck.status)
+            return PurchasesDiagnostics.OfferingDiagnosticsPayload(
+                identifier: offeringCheck.identifier,
+                packages: offeringCheck.packages.map { self.createPackagePayload(from: $0) },
+                status: status
+            )
+        }
+
+        return .offeringConfiguration(offeringPayloads)
+    }
+
+    private func convertOfferingStatus(_ status: HealthCheckStatus) -> PurchasesDiagnostics.SDKHealthCheckStatus {
+        switch status {
+        case .passed: return .passed
+        case .failed: return .failed
+        case .warning: return .warning
+        default: return .failed
+        }
+    }
+
+    private func createProductPayload(
+        from productReport: ProductHealthReport
+    ) -> PurchasesDiagnostics.ProductDiagnosticsPayload {
+        .init(
+            identifier: productReport.identifier,
+            title: productReport.title,
+            status: status(from: productReport.status),
+            description: description(
+                from: productReport.status,
+                identifier: productReport.identifier,
+                description: productReport.description
+            )
+        )
+    }
+
+    private func createPackagePayload(from packageReport: PackageHealthReport)
+        -> PurchasesDiagnostics.OfferingPackageDiagnosticsPayload {
+        .init(
+            identifier: packageReport.identifier,
+            title: packageReport.title,
+            status: status(from: packageReport.status),
+            description: description(
+                from: packageReport.status,
+                identifier: packageReport.productIdentifier,
+                description: packageReport.description
+            ),
+            productIdentifier: packageReport.productIdentifier,
+            productTitle: packageReport.productTitle
+        )
+    }
+
+    private func description(
+        from status: ProductStatus,
+        identifier: String,
+        description: String
+    ) -> String {
+        switch status {
+        case .valid:
+            return "Available for production purchases."
+        case .couldNotCheck:
+            return description
+        case .notFound:
+            return """
+                Product not found in App Store Connect. You need to create a product with identifier: \
+                '\(identifier)' in App Store Connect to use it for production purchases.
+                """
+        case .actionInProgress:
+            return """
+                Some process is ongoing and needs to be completed before using this product in production purchases, \
+                by Apple (state: \(description)). \
+                You can still make test purchases with the RevenueCat SDK, but you will need to \
+                wait for the state to change before you can make production purchases.
+                """
+        case .needsAction:
+            return """
+                This product's status (\(description)) requires you to take action in App Store Connect \
+                before using it in production purchases.
+                """
+        case .unknown:
+            return """
+                We could not check the status of your product using the App Store Connect API. \
+                Please check the app's credentials in the dashboard and try again.
+                """
+        }
+    }
+
+    private func status(from productCheckStatus: ProductStatus) -> PurchasesDiagnostics.ProductStatus {
+        switch productCheckStatus {
+        case .valid: .valid
+        case .couldNotCheck: .couldNotCheck
+        case .notFound: .notFound
+        case .actionInProgress: .actionInProgress
+        case .needsAction: .needsAction
+        case .unknown: .unknown
+        }
+    }
+}
diff --git a/Sources/Support/PurchasesDiagnostics.swift b/Sources/Support/PurchasesDiagnostics.swift
index 49ff754f07..cbf029be2b 100644
--- a/Sources/Support/PurchasesDiagnostics.swift
+++ b/Sources/Support/PurchasesDiagnostics.swift
@@ -12,6 +12,7 @@
 //  Created by Nacho Soto on 9/21/22.
 
 import Foundation
+import StoreKit
 
 /// `PurchasesDiagnostics` allows you to ensure that the SDK is set up correctly by diagnosing configuration errors.
 /// To run the test, simply call ``PurchasesDiagnostics/testSDKHealth()``.
@@ -26,9 +27,9 @@ import Foundation
 /// }
 /// ```
 @objc(RCPurchasesDiagnostics)
-public final class PurchasesDiagnostics: NSObject {
+public final class PurchasesDiagnostics: NSObject, Sendable {
 
-    typealias SDK = PurchasesType & InternalPurchasesType
+    typealias SDK = PurchasesType & InternalPurchasesType & Sendable
 
     private let purchases: SDK
 
@@ -44,6 +45,78 @@ public final class PurchasesDiagnostics: NSObject {
 
 extension PurchasesDiagnostics {
 
+    /// Enum representing the status of a product in the store
+    public enum ProductStatus: Sendable {
+        /// Product is configured correctly in App Store Connect
+        case valid
+        /// There was a problem checking the product state in App Store Connect
+        case couldNotCheck
+        /// The product does not exist in App Store Connect
+        case notFound
+        /// The product is in a state that requires action from Apple or the Developer before being ready for production
+        case actionInProgress
+        /// The product is in a state that requires action from the developer before being ready for production
+        case needsAction
+        /// The product state could not be determined
+        case unknown
+    }
+
+    /// Additional information behind a configuration issue for a Product
+    public struct ProductDiagnosticsPayload: Sendable {
+        /// Product identifier that must match the product in App Store Connect
+        public let identifier: String
+        /// Title of the product as it appears on the RevenueCat website
+        public let title: String?
+        /// Status of the RevenueCat product derived from the App Store Connect product
+        public let status: ProductStatus
+        /// Explainer of the product status
+        public let description: String
+    }
+
+    /// Additional information behind a configuration issue for the app's Bundle Id
+    public struct InvalidBundleIdErrorPayload {
+        /// Bundle ID for the RevenueCat app
+        public let appBundleId: String
+        /// Bundle ID detected from the app at runtime by the RevenueCat SDK
+        public let sdkBundleId: String
+    }
+
+    /// Health status for a specific validation check in the SDK's Health Report
+    public enum SDKHealthCheckStatus: Sendable {
+        /// SDK Health Check is valid
+        case passed
+        /// SDK Health Check is not valid
+        case failed
+        /// SDK Health Check is valid, but yielded some warnings
+        case warning
+    }
+
+    /// Additional information behind a configuration issue for a specific offering
+    public struct OfferingDiagnosticsPayload: Sendable {
+        /// Offering identifier as set up in the RevenueCat website
+        public let identifier: String
+        /// Extra information for each of the packages in the offering
+        public let packages: [OfferingPackageDiagnosticsPayload]
+        /// Status of the offering health check
+        public let status: SDKHealthCheckStatus
+    }
+
+    /// Additional information about a specific package in an offering that has a configuration issue.
+    public struct OfferingPackageDiagnosticsPayload: Sendable {
+        /// The identifier of the package as configured in the RevenueCat website.
+        public let identifier: String
+        /// The display name of the package, if available.
+        public let title: String?
+        /// The current configuration status of the underlying product in App Store Connect.
+        public let status: ProductStatus
+        /// A human-readable explanation of the product's configuration status.
+        public let description: String
+        /// The product identifier associated with this package.
+        public let productIdentifier: String
+        /// The reference name of the product from App Store Connect, if available.
+        public let productTitle: String?
+    }
+
     /// An error that represents a failing step in ``PurchasesDiagnostics``
     public enum Error: Swift.Error {
 
@@ -59,6 +132,21 @@ extension PurchasesDiagnostics {
         /// Failure performing a signed request
         case failedMakingSignedRequest(Swift.Error)
 
+        /// There are no offerings in project
+        case noOfferings
+
+        /// Offerings are not configured correctly
+        case offeringConfiguration([OfferingDiagnosticsPayload])
+
+        /// App bundle ID does not match the one set in the dashboard
+        case invalidBundleId(InvalidBundleIdErrorPayload?)
+
+        /// One or more products are not configured correctly
+        case invalidProducts([ProductDiagnosticsPayload])
+
+        /// The person is not authorized to make In-App Purchases
+        case notAuthorizedToMakePayments
+
         /// Any other not identifier error. You can check the undelying error for details.
         case unknown(Swift.Error)
 
@@ -66,29 +154,85 @@ extension PurchasesDiagnostics {
 
 }
 
-// MARK: - Implementation
-
 extension PurchasesDiagnostics {
+    /// A report that encapsulates the result of the SDK configuration health check.
+    /// Use this to programmatically inspect the SDK's health status after calling `healthReport()`.
+    public struct SDKHealthReport: Sendable {
+        /// The overall status of the SDK's health.
+        public let status: SDKHealthStatus
+        /// The RevenueCat project identifier associated with the current SDK configuration, if available.
+        public let projectId: String?
+        /// The RevenueCat app identifier associated with the current SDK configuration, if available.
+        public let appId: String?
+        /// The report for each of your app's products set up in the RevenueCat website
+        public let products: [ProductDiagnosticsPayload]
+        /// The report for each of your app's offerings set up in the RevenueCat website
+        public let offerings: [OfferingDiagnosticsPayload]
+
+        init(
+            status: SDKHealthStatus,
+            projectId: String? = nil,
+            appId: String? = nil,
+            products: [ProductDiagnosticsPayload] = [],
+            offerings: [OfferingDiagnosticsPayload] = []
+        ) {
+            self.status = status
+            self.projectId = projectId
+            self.appId = appId
+            self.products = products
+            self.offerings = offerings
+        }
+    }
 
-    /// Perform tests to ensure SDK is configured correctly.
-    /// - `Throws`: ``PurchasesDiagnostics/Error`` if any step fails
-    @objc(testSDKHealthWithCompletion:)
+    /// Status of the SDK Health report
+    public enum SDKHealthStatus: Sendable {
+        /// SDK configuration is valid but might have some non-blocking issues
+        case healthy(warnings: [PurchasesDiagnostics.Error])
+        /// SDK configuration is not valid and has issues that must be resolved
+        case unhealthy(PurchasesDiagnostics.Error)
+    }
+
+    #if DEBUG
+    /// Performs a full SDK configuration health check and throws an error if the configuration is not valid.
+    /// - Important: This method is intended solely for debugging configuration issues with the SDK implementation.
+    /// It should not be invoked in production builds.
+    /// - Throws: The specific configuration issue that needs to be solved.
     public func testSDKHealth() async throws {
-        do {
-            try await self.unauthenticatedRequest()
-            #if !ENABLE_CUSTOM_ENTITLEMENT_COMPUTATION
-            try await self.authenticatedRequest()
-            #endif
-            try await self.offeringsRequest()
-            try await self.signatureVerification()
-        } catch let error as Error {
-            throw error
-        } catch let error {
-            // Catch every other error to ensure that we only throw `Error`s from here.
-            throw Error.unknown(error)
+        switch await self.healthReport().status {
+        case let .unhealthy(error): throw error
+        default: break
         }
     }
 
+    private var canMakePayments: Bool {
+        if #available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) {
+            return AppStore.canMakePayments
+        } else {
+            return SKPaymentQueue.canMakePayments()
+        }
+    }
+
+    /// Performs a full SDK configuration health check and returns its status.
+    /// - Important: This method is intended solely for debugging configuration issues with the SDK implementation.
+    /// It should not be invoked in production builds.
+    /// - Returns: The result of the SDK configuration health check.
+    public func healthReport() async -> SDKHealthReport {
+        do {
+            if !canMakePayments {
+                return .init(status: .unhealthy(.notAuthorizedToMakePayments))
+            }
+            return try await self.purchases.healthReportRequest().validate()
+        } catch let error as BackendError {
+            if case .networkError(let networkError) = error,
+               case .errorResponse(let response, _, _) = networkError, response.code == .invalidAPIKey {
+                return .init(status: .unhealthy(.invalidAPIKey))
+            }
+            return .init(status: .unhealthy(.unknown(error)))
+        } catch {
+            return .init(status: .unhealthy(.unknown(error)))
+        }
+    }
+    #endif
 }
 
 // MARK: - Private
@@ -167,11 +311,40 @@ extension PurchasesDiagnostics.Error: CustomNSError {
 
     var localizedDescription: String {
         switch self {
+        case .notAuthorizedToMakePayments: return "The person is not authorized to make payments on this device"
         case let .unknown(error): return "Unknown error: \(error.localizedDescription)"
         case let .failedConnectingToAPI(error): return "Error connecting to API: \(error.localizedDescription)"
         case let .failedFetchingOfferings(error): return "Failed fetching offerings: \(error.localizedDescription)"
         case let .failedMakingSignedRequest(error): return "Failed making signed request: \(error.localizedDescription)"
         case .invalidAPIKey: return "API key is not valid"
+        case .noOfferings: return "No offerings configured"
+        case let .offeringConfiguration(payload):
+            guard let offendingOffering = payload.first(where: { $0.status == .failed }) else {
+                return "Default offering is not configured correctly"
+            }
+
+            let offeringIdentifier = offendingOffering.identifier
+            let offendingPackageCount = offendingOffering.packages.filter({ $0.status != .valid }).count
+            let noPackages = "Offering '\(offeringIdentifier)' has no packages"
+            let packagesNotReady = """
+            Offering '\(offeringIdentifier)' uses \(offendingPackageCount) products \
+            that are not ready in App Store Connect
+            """
+
+            return offendingOffering.packages.isEmpty ? noPackages : packagesNotReady
+        case let .invalidBundleId(payload):
+            guard let payload else {
+                return "Bundle ID in your app does not match the Bundle ID in the RevenueCat Website"
+            }
+            let sdkBundleId = payload.sdkBundleId
+            let appBundleId = payload.appBundleId
+            return "Bundle ID in your app '\(sdkBundleId)' does not match the RevenueCat app Bundle ID '\(appBundleId)'"
+        case let .invalidProducts(products):
+            if products.isEmpty {
+                return "Your app has no products"
+            }
+
+            return "You must have at least one product approved in App Store Connect"
         }
     }
 
@@ -181,7 +354,13 @@ extension PurchasesDiagnostics.Error: CustomNSError {
         case let .failedConnectingToAPI(error): return error
         case let .failedFetchingOfferings(error): return error
         case let .failedMakingSignedRequest(error): return error
-        case .invalidAPIKey: return nil
+        case .invalidAPIKey,
+                .offeringConfiguration,
+                .noOfferings,
+                .invalidBundleId,
+                .invalidProducts,
+                .notAuthorizedToMakePayments:
+            return nil
         }
     }
 
diff --git a/Tests/StoreKitUnitTests/Support/DebugViewSwiftUITests.swift b/Tests/StoreKitUnitTests/Support/DebugViewSwiftUITests.swift
index 74cfabe5b8..3e9142958c 100644
--- a/Tests/StoreKitUnitTests/Support/DebugViewSwiftUITests.swift
+++ b/Tests/StoreKitUnitTests/Support/DebugViewSwiftUITests.swift
@@ -45,7 +45,7 @@ class DebugViewSwiftUITests: TestCase {
             verificationMode: "Enforced",
             receiptURL: URL(string: "file://receipt")
         ))
-        model.diagnosticsResult = .loaded(())
+        model.diagnosticsResult = .loaded(.init(status: .healthy(warnings: [])))
         model.customerInfo = .loaded(try Self.mockCustomerInfo)
         model.currentAppUserID = "Nacho"
         model.offerings = .loaded(.init(
diff --git a/Tests/UnitTests/Misc/PurchasesDiagnosticsTests.swift b/Tests/UnitTests/Misc/PurchasesDiagnosticsTests.swift
index b010a206e7..d9ecc92d90 100644
--- a/Tests/UnitTests/Misc/PurchasesDiagnosticsTests.swift
+++ b/Tests/UnitTests/Misc/PurchasesDiagnosticsTests.swift
@@ -43,75 +43,16 @@ class PurchasesDiagnosticsTests: TestCase {
     }
 
     func testFailingHealthRequest() async throws {
-        let error = ErrorUtils.offlineConnectionError().asPublicError
-        self.purchases.mockedHealthRequestResponse = .failure(error)
-
-        do {
-            try await self.diagnostics.testSDKHealth()
-            fail("Expected error")
-        } catch let PurchasesDiagnostics.Error.failedConnectingToAPI(underlyingError) {
-            expect(underlyingError).to(matchError(error))
-        } catch {
-            fail("Unexpected error: \(error)")
-        }
-    }
-
-    func testFailingAuthenticatedRequest() async throws {
-        let error = ErrorUtils
-            .backendError(withBackendCode: .invalidAPIKey,
-                          originalBackendErrorCode: BackendErrorCode.invalidAPIKey.rawValue,
-                          backendMessage: "Invalid API key")
-            .asPublicError
-        self.purchases.mockedCustomerInfoResponse = .failure(error)
-
-        do {
-            try await self.diagnostics.testSDKHealth()
-            fail("Expected error")
-        } catch PurchasesDiagnostics.Error.invalidAPIKey {
-            // Expected error
-        } catch {
-            fail("Unexpected error: \(error)")
-        }
-    }
-
-    func testFailingOfferingsRequest() async throws {
-        let error = OfferingsManager.Error.missingProducts(identifiers: ["a"]).asPublicError
-        self.purchases.mockedOfferingsResponse = .failure(error)
-
-        do {
-            try await self.diagnostics.testSDKHealth()
-            fail("Expected error")
-        } catch let PurchasesDiagnostics.Error.failedFetchingOfferings(offeringsError) {
-            expect(offeringsError).to(matchError(error))
-            expect(self.purchases.invokedGetOfferingsParameters) == .failIfProductsAreMissing
-        } catch {
-            fail("Unexpected error: \(error)")
-        }
-    }
-
-    func testDoesNotCheckSignatureVerificationIfDisabled() async throws {
-        self.purchases.mockedResponseVerificationMode = .disabled
-
-        self.purchases.mockedHealthRequestWithSignatureVerificationResponse = .failure(
-            ErrorUtils.signatureVerificationFailedError(path: HTTPRequest.Path.health.relativePath,
-                                                        code: .success).asPublicError
+        self.purchases.mockedHealthReportRequestResponse = .success(
+            HealthReport(status: .failed, projectId: nil, appId: nil, checks: [
+                HealthCheck(name: HealthCheckType.apiKey, status: HealthCheckStatus.failed)
+            ])
         )
-
-        try await self.diagnostics.testSDKHealth()
-    }
-
-    func testFailingSignatureVerification() async throws {
-        self.purchases.mockedResponseVerificationMode = Signing.verificationMode(with: .informational)
-
-        let expectedError = ErrorUtils.signatureVerificationFailedError(path: HTTPRequest.Path.health.relativePath,
-                                                                        code: .success)
-        self.purchases.mockedHealthRequestWithSignatureVerificationResponse = .failure(expectedError.asPublicError)
-
         do {
             try await self.diagnostics.testSDKHealth()
             fail("Expected error")
-        } catch let PurchasesDiagnostics.Error.failedMakingSignedRequest(error) {
-            expect(error).to(matchError(expectedError))
+        } catch PurchasesDiagnostics.Error.invalidAPIKey {
+            /* Test succeeds */
         } catch {
             fail("Unexpected error: \(error)")
         }
@@ -158,4 +99,101 @@ class PurchasesDiagnosticsTests: TestCase {
         expect(error.localizedDescription) == "Failed fetching offerings: \(underlyingError.localizedDescription)"
     }
 
+    func testNoOfferings() {
+        let error = PurchasesDiagnostics.Error.noOfferings
+
+        expect(error.errorUserInfo[NSUnderlyingErrorKey] as? NSNull).toNot(beNil())
+        expect(error.localizedDescription) == "No offerings configured"
+    }
+
+    func testOfferingConfigurationError() {
+        let error = PurchasesDiagnostics.Error.offeringConfiguration(
+            [
+                .init(
+                    identifier: "test_offering",
+                    packages: [
+                        .init(
+                            identifier: "failing_package",
+                            title: "Failing Package",
+                            status: .notFound,
+                            description: "Could not find package",
+                            productIdentifier: "failing_product_identifier",
+                            productTitle: "Failing Product Title"
+                        )
+                    ],
+                    status: .failed
+                )
+            ]
+        )
+
+        expect(error.errorUserInfo[NSUnderlyingErrorKey] as? NSNull).toNot(beNil())
+        let expected = "Offering 'test_offering' uses 1 products that are not ready in App Store Connect"
+        expect(error.localizedDescription) == expected
+    }
+
+    func testOfferingConfigurationWithNoPackages() {
+        let error = PurchasesDiagnostics.Error.offeringConfiguration(
+            [
+                .init(
+                    identifier: "test_offering",
+                    packages: [],
+                    status: .failed
+                )
+            ]
+        )
+
+        expect(error.errorUserInfo[NSUnderlyingErrorKey] as? NSNull).toNot(beNil())
+        let expected = "Offering 'test_offering' has no packages"
+        expect(error.localizedDescription) == expected
+    }
+
+    func testGenericOfferingConfigurationError() {
+        let error = PurchasesDiagnostics.Error.offeringConfiguration([])
+
+        expect(error.errorUserInfo[NSUnderlyingErrorKey] as? NSNull).toNot(beNil())
+        let expected = "Default offering is not configured correctly"
+        expect(error.localizedDescription) == expected
+    }
+
+    func testBundleIdError() {
+        let error = PurchasesDiagnostics.Error.invalidBundleId(
+            .init(
+                appBundleId: "app_bundle_id",
+                sdkBundleId: "sdk_bundle_id"
+            )
+        )
+
+        expect(error.errorUserInfo[NSUnderlyingErrorKey] as? NSNull).toNot(beNil())
+        let expected = """
+        Bundle ID in your app 'sdk_bundle_id' does not match \
+        the RevenueCat app Bundle ID 'app_bundle_id'
+        """
+        expect(error.localizedDescription) == expected
+    }
+
+    func testGenericBundleIdError() {
+        let error = PurchasesDiagnostics.Error.invalidBundleId(nil)
+
+        expect(error.errorUserInfo[NSUnderlyingErrorKey] as? NSNull).toNot(beNil())
+        let expected = "Bundle ID in your app does not match the Bundle ID in the RevenueCat Website"
+        expect(error.localizedDescription) == expected
+    }
+
+    func testNoProductsError() {
+        let error = PurchasesDiagnostics.Error.invalidProducts([])
+
+        expect(error.errorUserInfo[NSUnderlyingErrorKey] as? NSNull).toNot(beNil())
+        let expected = "Your app has no products"
+        expect(error.localizedDescription) == expected
+    }
+
+    func testAtLeastOneValidProductError() {
+        let error = PurchasesDiagnostics.Error.invalidProducts([
+            .init(identifier: "", title: nil, status: .notFound, description: "")
+        ])
+
+        expect(error.errorUserInfo[NSUnderlyingErrorKey] as? NSNull).toNot(beNil())
+        let expected = "You must have at least one product approved in App Store Connect"
+        expect(error.localizedDescription) == expected
+    }
 }
diff --git a/Tests/UnitTests/Mocks/MockPurchases.swift b/Tests/UnitTests/Mocks/MockPurchases.swift
index bdc838a620..f1a9d27f4b 100644
--- a/Tests/UnitTests/Mocks/MockPurchases.swift
+++ b/Tests/UnitTests/Mocks/MockPurchases.swift
@@ -48,6 +48,10 @@ final class MockPurchases {
 
     var mockedResponseVerificationMode: Signing.ResponseVerificationMode = .disabled
 
+    var mockedHealthReportRequestResponse: Result<HealthReport, PublicError> = .success(
+        HealthReport(status: .passed, projectId: nil, appId: nil, checks: [])
+    )
+
 }
 
 extension MockPurchases: InternalPurchasesType {
@@ -60,6 +64,10 @@ extension MockPurchases: InternalPurchasesType {
         }
     }
 
+    func healthReportRequest() async throws -> HealthReport {
+        return try self.mockedHealthReportRequestResponse.get()
+    }
+
     @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *)
     func productEntitlementMapping() async throws -> ProductEntitlementMapping {
         return try self.mockedProductEntitlementMapping.get()


Reviews:
user: login: ajpallares
body: Looking good so far! Thank you! Some initial comments, very small things.

I need to finish reviewing it though, mostly the UI parts. 

The only thing that feels weird to me, is that I would think that in order to check the health status of the configuration, the `appUserID` shouldn't be needed. I saw the code comment where it's explained that it's needed for fetching the offerings, but, is it really really needed? I'm curious.
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-10T17:12:00Z
user: login: aboedo
body: this is amazing Pol!! I left a few comments but I absolutely love it
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-10T19:34:55Z
user: login: polpielladev
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-11T09:11:04Z
user: login: polpielladev
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-11T09:12:00Z
user: login: polpielladev
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-11T09:12:47Z
user: login: polpielladev
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-11T09:13:53Z
user: login: polpielladev
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-11T09:15:17Z
user: login: polpielladev
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-11T09:17:03Z
user: login: polpielladev
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-11T10:19:12Z
user: login: polpielladev
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-11T10:21:17Z
user: login: polpielladev
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-11T10:36:31Z
user: login: polpielladev
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-11T10:37:17Z
user: login: polpielladev
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-11T10:38:24Z
user: login: polpielladev
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-11T10:40:23Z
user: login: polpielladev
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-11T11:15:27Z
user: login: polpielladev
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-11T13:15:19Z
user: login: polpielladev
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-11T13:18:27Z
user: login: polpielladev
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-11T13:20:13Z
user: login: aboedo
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-11T14:52:26Z
user: login: aboedo
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-11T14:53:32Z
user: login: aboedo
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-11T14:54:36Z
user: login: polpielladev
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-14T11:34:43Z
user: login: ajpallares
body: This is Awesome. Thanks so much Pol! It will be extremely users for our users (and for us 😄)! To me it looks great!
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-16T07:45:02Z
user: login: polpielladev
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-24T09:35:59Z
user: login: ajpallares
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-24T09:38:43Z
user: login: polpielladev
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-24T09:39:24Z

Review comments:
diff_hunk: @@ -158,6 +158,24 @@ extension Backend {
 
 }
 
+extension Backend {
+
+    /// Call the `/health_report` endpoint and perform a full validation of the SDK's configuration
+    /// - Parameter appUserID: An `appUserID` that allows the Back End to fetch offerings
+    /// - Returns: A report will all validation checks along with their status
path: Sources/Networking/Backend.swift
user: login: ajpallares
body: ```suggestion
    /// Call the `/health_report` endpoint and perform a full validation of the SDK's configuration
    /// - Parameter appUserID: An `appUserID` that allows the Backend to fetch offerings
    /// - Returns: A report with all validation checks along with their status
```
created_at: 2025-04-10T16:36:22Z
author_association: MEMBER
diff_hunk: @@ -158,6 +158,24 @@ extension Backend {
 
 }
 
+extension Backend {
+
+    /// Call the `/health_report` endpoint and perform a full validation of the SDK's configuration
+    /// - Parameter appUserID: An `appUserID` that allows the Back End to fetch offerings
+    /// - Returns: A report will all validation checks along with their status
+    func healthReportRequest(appUserID: String) async throws -> HealthReport {
+        try await Async.call { (completion: @escaping (Result<HealthReport, BackendError>) -> Void) in
+            self.internalAPI.healthReportRequest(appUserID: appUserID) { result in
+                switch result {
+                case let .success(response): completion(.success(response))
+                case .failure(let error): completion(.failure(error))
+                }
+            }
path: Sources/Networking/Backend.swift
user: login: ajpallares
body: I think this can be simplified to just
```suggestion
            self.internalAPI.healthReportRequest(appUserID: appUserID, completion: completion)
```
created_at: 2025-04-10T16:39:39Z
author_association: MEMBER
diff_hunk: @@ -260,6 +268,9 @@ extension HTTPRequest.Path: HTTPRequestPath {
         case .postRedeemWebPurchase:
             return "post_redeem_web_purchase"
 
+        case .appHealthReport:
+            return "get_app_health_report"
+
path: Sources/Networking/HTTPClient/HTTPRequestPath.swift
user: login: aboedo
body: we can do it as a follow-up, but we might as well remove the other health API and instead bridge internally to this one
created_at: 2025-04-10T19:17:06Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,73 @@
+//
+//  Copyright RevenueCat Inc. All Rights Reserved.
+//
+//  Licensed under the MIT License (the "License");
+//  you may not use this file except in compliance with the License.
+//  You may obtain a copy of the License at
+//
+//      https://opensource.org/licenses/MIT
+//
+//  HealthReportOperation.swift
+//
+//  Created by Pol Piella on 4/8/25.
path: Sources/Networking/Operations/HealthReportOperation.swift
user: login: aboedo
body: one of us one of us 
![one of us](https://media4.giphy.com/media/v1.Y2lkPTc5MGI3NjExOHl4ZXkxN3F6b2Jhdmt2aHp5bXg2NXJmbzUzaHpzbXowd2EzdDk2ZyZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/Ae7SI3LoPYj8Q/giphy.gif)
created_at: 2025-04-10T19:20:27Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,133 @@
+//
+//  Copyright RevenueCat Inc. All Rights Reserved.
+//
+//  Licensed under the MIT License (the "License");
+//  you may not use this file except in compliance with the License.
+//  You may obtain a copy of the License at
+//
+//      https://opensource.org/licenses/MIT
+//
+//  HealthReportResponse.swift
+//
+//  Created by Pol Piella on 4/8/25.
+
+import Foundation
+
+enum HealthCheckStatus: String {
+    case passed
+    case failed
+    case warning
+    case unknown
+}
+
+enum HealthCheckType: String {
+    case apiKey = "api_key"
+    case sdkVersion = "sdk_version"
+    case bundleId = "bundle_id"
+    case products = "products"
+    case offerings = "offerings"
+    case offeringsProducts = "offerings_products"
+}
+
+enum ProductStatus: String {
+    case valid = "ok"
+    case couldNotCheck = "could_not_check"
+    case notFound = "not_found"
+    case actionInProgress = "action_in_progress"
+    case needsAction = "needs_action"
+    case unknown
+}
+
+struct PackageHealthReport {
+    let identifier: String
+    let title: String?
+    let status: ProductStatus
+    let description: String
+    let productIdentifier: String
+    let productTitle: String?
+}
+
+struct OfferingHealthReport {
+    let identifier: String
+    let packages: [PackageHealthReport]
+    let status: HealthCheckStatus
+}
+
+struct OfferingsCheckDetails {
+    let offerings: [OfferingHealthReport]
+}
+
+struct BundleIdCheckDetails {
+    let sdkBundleId: String
+    let appBundleId: String
+}
+
+enum HealthCheckDetails {
+    case offeringsProducts(OfferingsCheckDetails)
+    case bundleId(BundleIdCheckDetails)
+    case products(ProductsCheckDetails)
+}
+
+struct ProductsCheckDetails {
+    let products: [ProductHealthReport]
+}
+
+struct ProductHealthReport {
+    let identifier: String
+    let title: String?
+    let status: ProductStatus
+    let description: String
+}
+
+struct HealthCheck {
+    let name: HealthCheckType
+    let status: HealthCheckStatus
+    let details: HealthCheckDetails?
+
+    enum CodingKeys: String, CodingKey {
+        case name
+        case status
+        case details
+    }
+
+    init(from decoder: Decoder) throws {
+        let container = try decoder.container(keyedBy: CodingKeys.self)
+        name = try container.decode(HealthCheckType.self, forKey: .name)
+        status = try container.decode(HealthCheckStatus.self, forKey: .status)
+
+        switch name {
+        case .offeringsProducts:
+            details = (try container.decodeIfPresent(OfferingsCheckDetails.self, forKey: .details))
+                .map({ .offeringsProducts($0) })
+        case .bundleId:
+            details = (try container.decodeIfPresent(BundleIdCheckDetails.self, forKey: .details))
+                .map({ .bundleId($0) })
+
+        case .products:
+            details = (try container.decodeIfPresent(ProductsCheckDetails.self, forKey: .details))
+                .map({ .products($0) })
+
+        default:
+            details = nil
+        }
+    }
path: Sources/Networking/Responses/HealthReportResponse.swift
user: login: aboedo
body: This bit is mostly so that if the value isn't present, the call doesn't entirely fail, right? Maybe in this case, we don't really need it? We are trying to find out what could go wrong after all. It might be the case where a high level catch works
created_at: 2025-04-10T19:22:20Z
author_association: MEMBER
diff_hunk: @@ -1213,6 +1213,11 @@ internal protocol InternalPurchasesType: AnyObject {
     /// - Throws: `PublicError` if request failed.
     func healthRequest(signatureVerification: Bool) async throws
 
+    /// Asks the server for an in-depth report of the SDK's configuration
+    /// - Throws: A `BackendError` if the request fails for an invalid API key or for connectivity issues
+    /// - Returns: A health report with all checks performed in the server and their status
path: Sources/Purchasing/Purchases/PurchasesType.swift
user: login: aboedo
body: let's add a bit more detail about _when_ you want to use this. 
Or maybe even keep it internal but automatically call it in debug builds? 

Otherwise, this could easily get called in a loop or like on every screen shown in a mega app
created_at: 2025-04-10T19:23:33Z
author_association: MEMBER
diff_hunk: @@ -414,6 +435,78 @@ private struct DebugPackageView: View {
 
 }
 
+@available(iOS 16.0, macOS 13.0, *)
+private struct DebugStatusDetailsView: View {
+    let errors: [PurchasesDiagnostics.Error]
+
+    var body: some View {
+        List {
+            ForEach(errors, id: \.errorCode) { error in
+                switch error {
+                case let .invalidProducts(products):
+                    Section("Product Validation Issues") {
+                        ForEach(products, id: \.identifier) { product in
+                            if product.status != .valid {
+                                VStack(alignment: .leading, spacing: 8) {
+                                    HStack(alignment: .center, spacing: 8) {
+                                        product.status.icon.imageScale(.small)
+                                        Text(product.identifier)
+                                    }
+                                    Text(product.statusMessage)
+                                        .foregroundStyle(.secondary)
+                                        .font(.caption)
+                                }
+                            }
+                        }
+                    }
+                case .noOfferings:
+                    Section("Offering Validation Issues") {
+                        VStack(alignment: .leading, spacing: 8) {
+                            HStack(alignment: .center, spacing: 8) {
+                                Image(systemName: "exclamationmark.triangle.fill")
+                                    .foregroundColor(.yellow)
+                                Text("No offerings configured")
+                            }
+                            Text(
+                                """
+                                While offerings are not mandatory, they are the way to 'offer' products \
+                                to your customers on your paywalls.
+                                """
+                            )
+                            .font(.caption)
+                            .foregroundStyle(.secondary)
+                        }
+                    }
+                case let .offeringConfiguration(offerings):
+                    Section("Offering Validation Issues") {
+                        ForEach(offerings, id: \.identifier) { offering in
+                            if offering.status != .passed {
+                                VStack(alignment: .leading, spacing: 8) {
+                                    HStack(alignment: .center, spacing: 8) {
+                                        offering.status.icon.imageScale(.small)
+                                        Text(offering.identifier)
+                                    }
+                                    Text(
+                                        offering.packages.isEmpty ?
+                                        "Offerings must have at least one package" :
+                                        """
+                                        Products \(offering.packages.map { "'\($0.productIdentifier)'" }.formatted()) \
+                                        in the offering have issues, check the products list above for more details.
+                                        """
path: Sources/Support/DebugUI/DebugContentViews.swift
user: login: aboedo
body: this is unreasonably exciting to me
created_at: 2025-04-10T19:24:27Z
author_association: MEMBER
diff_hunk: @@ -83,8 +83,27 @@ extension DebugViewModel {
     var diagnosticsStatus: String {
         switch self.diagnosticsResult {
         case .loading: return "Loading..."
-        case .loaded: return "Configuration OK"
-        case let .failed(error): return "Error: \(error.localizedDescription)"
+        case let .loaded(healthStatus):
+            switch healthStatus {
+            case .healthy: return "Configuration OK"
+            case .unhealthy: return "Invalid Configuration"
+            }
+        }
+    }
+
+    var diagnosticsExplainer: String? {
+        switch self.diagnosticsResult {
+        case let .loaded(healthStatus):
+            switch healthStatus {
+            case .healthy(warnings: let warnings):
+                return warnings.count > 0 ? """
+                The dashboard configuration is valid, however we encountered some potential issues \
path: Sources/Support/DebugUI/DebugViewModel.swift
user: login: aboedo
body: what's "the dashboard"? 
Feels like this copy bakes in the assumption that external developers think of the dashboard the same we do, but it's really an internal name. 

We can point to revenuecat.com, or mention "the revenuecat web app" or even "your revenuecat configuration", but dashboard might not mean anything to the reader. 

created_at: 2025-04-10T19:26:19Z
author_association: MEMBER
diff_hunk: @@ -44,6 +45,63 @@ public final class PurchasesDiagnostics: NSObject {
 
 extension PurchasesDiagnostics {
 
+    /// Enum representing the status of a product in the store
+    public enum ProductStatus {
+        /// Product is configured correctly in App Store Connect
+        case valid
+        /// There was a problem checking the product state in App Store Connect
+        case couldNotCheck
+        /// The product does not exist in App Store Connect
+        case notFound
+        /// The product is in a state that requires action from Apple or the Developer before being ready for production
+        case actionInProgress
path: Sources/Support/PurchasesDiagnostics.swift
user: login: aboedo
body: crying tears of joy
created_at: 2025-04-10T19:27:07Z
author_association: MEMBER
diff_hunk: @@ -167,11 +263,41 @@ extension PurchasesDiagnostics.Error: CustomNSError {
 
     var localizedDescription: String {
         switch self {
+        case .notAuthorizedToMakePayments: return "The person is not authorized to make payments on this device."
         case let .unknown(error): return "Unknown error: \(error.localizedDescription)"
         case let .failedConnectingToAPI(error): return "Error connecting to API: \(error.localizedDescription)"
         case let .failedFetchingOfferings(error): return "Failed fetching offerings: \(error.localizedDescription)"
         case let .failedMakingSignedRequest(error): return "Failed making signed request: \(error.localizedDescription)"
         case .invalidAPIKey: return "API key is not valid"
+        case .invalidSDKVersion: return "SDK Version is not supported"
path: Sources/Support/PurchasesDiagnostics.swift
user: login: aboedo
body: when does this one happen?
created_at: 2025-04-10T19:28:02Z
author_association: MEMBER
diff_hunk: @@ -167,11 +263,41 @@ extension PurchasesDiagnostics.Error: CustomNSError {
 
     var localizedDescription: String {
         switch self {
+        case .notAuthorizedToMakePayments: return "The person is not authorized to make payments on this device."
path: Sources/Support/PurchasesDiagnostics.swift
user: login: aboedo
body: :chefskiss:
created_at: 2025-04-10T19:28:11Z
author_association: MEMBER
diff_hunk: @@ -59,36 +117,74 @@ extension PurchasesDiagnostics {
         /// Failure performing a signed request
         case failedMakingSignedRequest(Swift.Error)
 
+        /// Version of the SDK is not supported
+        case invalidSDKVersion
+
+        /// There are no offerings in project
+        case noOfferings
+
+        /// Offerings are not configured correctly
+        case offeringConfiguration([OfferingConfigurationErrorPayload])
+
+        /// App bundle ID does not match the one set in the dashboard
+        case invalidBundleId(InvalidBundleIdErrorPayload?)
+
+        /// One or more products are not configured correctly
+        case invalidProducts([InvalidProductErrorPayload])
+
+        /// The person is not authorized to make In-App Purchases
+        case notAuthorizedToMakePayments
+
         /// Any other not identifier error. You can check the undelying error for details.
         case unknown(Swift.Error)
 
     }
 
 }
 
-// MARK: - Implementation
-
 extension PurchasesDiagnostics {
+    /// Status of the SDK Health report
+    public enum SDKHealthStatus {
+        /// SDK configuration is valid but might have some non-blocking issues
+        case healthy(warnings: [PurchasesDiagnostics.Error])
+        /// SDK configuration is not valid and has issues that must be resolved
+        case unhealthy(PurchasesDiagnostics.Error)
+    }
 
-    /// Perform tests to ensure SDK is configured correctly.
-    /// - `Throws`: ``PurchasesDiagnostics/Error`` if any step fails
-    @objc(testSDKHealthWithCompletion:)
+    /// Throw an error if the SDK is not configured correctly
     public func testSDKHealth() async throws {
-        do {
-            try await self.unauthenticatedRequest()
-            #if !ENABLE_CUSTOM_ENTITLEMENT_COMPUTATION
-            try await self.authenticatedRequest()
-            #endif
-            try await self.offeringsRequest()
-            try await self.signatureVerification()
-        } catch let error as Error {
-            throw error
-        } catch let error {
-            // Catch every other error to ensure that we only throw `Error`s from here.
-            throw Error.unknown(error)
+        switch await self.healthReport() {
+        case let .unhealthy(error): throw error
+        default: break
         }
     }
 
+    private var canMakePayments: Bool {
+        if #available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) {
+            return AppStore.canMakePayments
+        } else {
+            return SKPaymentQueue.canMakePayments()
+        }
+    }
path: Sources/Support/PurchasesDiagnostics.swift
user: login: aboedo
body: we already have our own API available through Purchases, let's use that instead
created_at: 2025-04-10T19:28:43Z
author_association: MEMBER
diff_hunk: @@ -59,36 +117,74 @@ extension PurchasesDiagnostics {
         /// Failure performing a signed request
         case failedMakingSignedRequest(Swift.Error)
 
+        /// Version of the SDK is not supported
+        case invalidSDKVersion
+
+        /// There are no offerings in project
+        case noOfferings
+
+        /// Offerings are not configured correctly
+        case offeringConfiguration([OfferingConfigurationErrorPayload])
+
+        /// App bundle ID does not match the one set in the dashboard
+        case invalidBundleId(InvalidBundleIdErrorPayload?)
+
+        /// One or more products are not configured correctly
+        case invalidProducts([InvalidProductErrorPayload])
+
+        /// The person is not authorized to make In-App Purchases
+        case notAuthorizedToMakePayments
+
         /// Any other not identifier error. You can check the undelying error for details.
         case unknown(Swift.Error)
 
     }
 
 }
 
-// MARK: - Implementation
-
 extension PurchasesDiagnostics {
+    /// Status of the SDK Health report
+    public enum SDKHealthStatus {
+        /// SDK configuration is valid but might have some non-blocking issues
+        case healthy(warnings: [PurchasesDiagnostics.Error])
+        /// SDK configuration is not valid and has issues that must be resolved
+        case unhealthy(PurchasesDiagnostics.Error)
+    }
 
-    /// Perform tests to ensure SDK is configured correctly.
-    /// - `Throws`: ``PurchasesDiagnostics/Error`` if any step fails
-    @objc(testSDKHealthWithCompletion:)
+    /// Throw an error if the SDK is not configured correctly
     public func testSDKHealth() async throws {
-        do {
-            try await self.unauthenticatedRequest()
-            #if !ENABLE_CUSTOM_ENTITLEMENT_COMPUTATION
-            try await self.authenticatedRequest()
-            #endif
-            try await self.offeringsRequest()
-            try await self.signatureVerification()
-        } catch let error as Error {
-            throw error
-        } catch let error {
-            // Catch every other error to ensure that we only throw `Error`s from here.
-            throw Error.unknown(error)
+        switch await self.healthReport() {
+        case let .unhealthy(error): throw error
+        default: break
         }
     }
 
+    private var canMakePayments: Bool {
+        if #available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) {
+            return AppStore.canMakePayments
+        } else {
+            return SKPaymentQueue.canMakePayments()
+        }
+    }
path: Sources/Support/PurchasesDiagnostics.swift
user: login: aboedo
body: also I'm now realizing that we haven't updated it for SK2 (cc @fire-at-will @MarkVillacampa) so we might as well add replace that method's implementation with this one lol 
https://github.com/RevenueCat/purchases-ios/blob/d9626ab8574c88f1bbde7e2610e2a7adcebfde48/Sources/Purchasing/Purchases/Purchases.swift#L162

(can be a separate PR, this isn't exactly urgent or blocking)
created_at: 2025-04-10T19:30:18Z
author_association: MEMBER
diff_hunk: @@ -167,11 +263,41 @@ extension PurchasesDiagnostics.Error: CustomNSError {
 
     var localizedDescription: String {
         switch self {
+        case .notAuthorizedToMakePayments: return "The person is not authorized to make payments on this device."
         case let .unknown(error): return "Unknown error: \(error.localizedDescription)"
         case let .failedConnectingToAPI(error): return "Error connecting to API: \(error.localizedDescription)"
         case let .failedFetchingOfferings(error): return "Failed fetching offerings: \(error.localizedDescription)"
         case let .failedMakingSignedRequest(error): return "Failed making signed request: \(error.localizedDescription)"
         case .invalidAPIKey: return "API key is not valid"
+        case .invalidSDKVersion: return "SDK Version is not supported"
+        case .noOfferings: return "No offerings configured"
+        case let .offeringConfiguration(payload):
+            guard let offendingOffering = payload.first(where: { $0.status == .failed }) else {
+                return "Default offering is not configured correctly"
+            }
+
+            let offeringIdentifier = offendingOffering.identifier
+            let offendingPackageCount = offendingOffering.packages.filter({ $0.status != .valid }).count
+            let noPackages = "Offering '\(offeringIdentifier)' has no packages"
+            let packagesNotReady = """
+            Offering '\(offeringIdentifier)' uses \(offendingPackageCount) products \
+            that are not ready in App Store Connect.
+            """
+
+            return offendingOffering.packages.isEmpty ? noPackages : packagesNotReady
+        case let .invalidBundleId(payload):
+            guard let payload else {
+                return "Bundle ID in your app does not match the Bundle ID in the RevenueCat dashboard"
+            }
+            let sdkBundleId = payload.sdkBundleId
+            let appBundleId = payload.appBundleId
+            return "Bundle ID in your app '\(sdkBundleId)' does not match the RevenueCat app Bundle ID '\(appBundleId)'"
path: Sources/Support/PurchasesDiagnostics.swift
user: login: aboedo
body: I'm confused about these two, which one is which? 
does "app" here represent the configuration in the web app? 
created_at: 2025-04-10T19:32:26Z
author_association: MEMBER
diff_hunk: @@ -167,11 +263,41 @@ extension PurchasesDiagnostics.Error: CustomNSError {
 
     var localizedDescription: String {
         switch self {
+        case .notAuthorizedToMakePayments: return "The person is not authorized to make payments on this device."
         case let .unknown(error): return "Unknown error: \(error.localizedDescription)"
         case let .failedConnectingToAPI(error): return "Error connecting to API: \(error.localizedDescription)"
         case let .failedFetchingOfferings(error): return "Failed fetching offerings: \(error.localizedDescription)"
         case let .failedMakingSignedRequest(error): return "Failed making signed request: \(error.localizedDescription)"
         case .invalidAPIKey: return "API key is not valid"
+        case .invalidSDKVersion: return "SDK Version is not supported"
+        case .noOfferings: return "No offerings configured"
+        case let .offeringConfiguration(payload):
+            guard let offendingOffering = payload.first(where: { $0.status == .failed }) else {
+                return "Default offering is not configured correctly"
+            }
+
+            let offeringIdentifier = offendingOffering.identifier
+            let offendingPackageCount = offendingOffering.packages.filter({ $0.status != .valid }).count
+            let noPackages = "Offering '\(offeringIdentifier)' has no packages"
+            let packagesNotReady = """
+            Offering '\(offeringIdentifier)' uses \(offendingPackageCount) products \
+            that are not ready in App Store Connect.
+            """
+
+            return offendingOffering.packages.isEmpty ? noPackages : packagesNotReady
+        case let .invalidBundleId(payload):
+            guard let payload else {
+                return "Bundle ID in your app does not match the Bundle ID in the RevenueCat dashboard"
+            }
+            let sdkBundleId = payload.sdkBundleId
+            let appBundleId = payload.appBundleId
+            return "Bundle ID in your app '\(sdkBundleId)' does not match the RevenueCat app Bundle ID '\(appBundleId)'"
path: Sources/Support/PurchasesDiagnostics.swift
user: login: aboedo
body: would be killer to add a link to the app configuration in RC web so that we can put it in these and then you can easily go fix web side
created_at: 2025-04-10T19:33:13Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,45 @@
+//
+//  Copyright RevenueCat Inc. All Rights Reserved.
+//
+//  Licensed under the MIT License (the "License");
+//  you may not use this file except in compliance with the License.
+//  You may obtain a copy of the License at
+//
+//      https://opensource.org/licenses/MIT
+//
+//  InvalidProductErrorPayload+StatusMessage.swift
+//
+//  Created by Pol Piella on 4/10/25.
+
+import Foundation
+
+extension PurchasesDiagnostics.InvalidProductErrorPayload {
+    var statusMessage: String {
+        switch self.status {
+        case .valid:
+            return "Available for production purchases."
+        case .couldNotCheck:
+            return self.description
+        case .notFound:
+            return """
+                Product not found in App Store Connect. You need to create a product with identifier: \
+                '\(self.identifier)' in App Store Connect to use it for production purchases.
+                """
+        case .actionInProgress:
+            return """
+                Some process is ongoing and needs to be completed before using this product in production purchases, \
+                controlled either by the developer or by Apple (state: \(self.description))
path: Sources/Support/DebugUI/InvalidProductErrorPayload+StatusMessage.swift
user: login: aboedo
body: this is the only one that doesn't quite have remediation steps, what do I do if I run into this? 
created_at: 2025-04-10T19:34:14Z
author_association: MEMBER
diff_hunk: @@ -158,6 +158,24 @@ extension Backend {
 
 }
 
+extension Backend {
+
+    /// Call the `/health_report` endpoint and perform a full validation of the SDK's configuration
+    /// - Parameter appUserID: An `appUserID` that allows the Back End to fetch offerings
+    /// - Returns: A report will all validation checks along with their status
+    func healthReportRequest(appUserID: String) async throws -> HealthReport {
+        try await Async.call { (completion: @escaping (Result<HealthReport, BackendError>) -> Void) in
+            self.internalAPI.healthReportRequest(appUserID: appUserID) { result in
+                switch result {
+                case let .success(response): completion(.success(response))
+                case .failure(let error): completion(.failure(error))
+                }
+            }
path: Sources/Networking/Backend.swift
user: login: polpielladev
body: Ah! Good point! Updated now 💪 
created_at: 2025-04-11T09:11:03Z
author_association: COLLABORATOR
diff_hunk: @@ -260,6 +268,9 @@ extension HTTPRequest.Path: HTTPRequestPath {
         case .postRedeemWebPurchase:
             return "post_redeem_web_purchase"
 
+        case .appHealthReport:
+            return "get_app_health_report"
+
path: Sources/Networking/HTTPClient/HTTPRequestPath.swift
user: login: polpielladev
body: Oh yeah 100%, this is the next PR that I will be doing, getting rid of the calls to existing health endpoint from the SDK 💪 
created_at: 2025-04-11T09:12:00Z
author_association: COLLABORATOR
diff_hunk: @@ -260,6 +268,9 @@ extension HTTPRequest.Path: HTTPRequestPath {
         case .postRedeemWebPurchase:
             return "post_redeem_web_purchase"
 
+        case .appHealthReport:
+            return "get_app_health_report"
+
path: Sources/Networking/HTTPClient/HTTPRequestPath.swift
user: login: polpielladev
body: Logged it as a ticket: https://linear.app/revenuecat/issue/DX-366/clean-up-the-old-health-endpoint-calls-from-the-sdk
created_at: 2025-04-11T09:12:47Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,73 @@
+//
+//  Copyright RevenueCat Inc. All Rights Reserved.
+//
+//  Licensed under the MIT License (the "License");
+//  you may not use this file except in compliance with the License.
+//  You may obtain a copy of the License at
+//
+//      https://opensource.org/licenses/MIT
+//
+//  HealthReportOperation.swift
+//
+//  Created by Pol Piella on 4/8/25.
path: Sources/Networking/Operations/HealthReportOperation.swift
user: login: polpielladev
body: Let's goooo!!! ❤️🚀 
created_at: 2025-04-11T09:13:53Z
author_association: COLLABORATOR
diff_hunk: @@ -414,6 +435,78 @@ private struct DebugPackageView: View {
 
 }
 
+@available(iOS 16.0, macOS 13.0, *)
+private struct DebugStatusDetailsView: View {
+    let errors: [PurchasesDiagnostics.Error]
+
+    var body: some View {
+        List {
+            ForEach(errors, id: \.errorCode) { error in
+                switch error {
+                case let .invalidProducts(products):
+                    Section("Product Validation Issues") {
+                        ForEach(products, id: \.identifier) { product in
+                            if product.status != .valid {
+                                VStack(alignment: .leading, spacing: 8) {
+                                    HStack(alignment: .center, spacing: 8) {
+                                        product.status.icon.imageScale(.small)
+                                        Text(product.identifier)
+                                    }
+                                    Text(product.statusMessage)
+                                        .foregroundStyle(.secondary)
+                                        .font(.caption)
+                                }
+                            }
+                        }
+                    }
+                case .noOfferings:
+                    Section("Offering Validation Issues") {
+                        VStack(alignment: .leading, spacing: 8) {
+                            HStack(alignment: .center, spacing: 8) {
+                                Image(systemName: "exclamationmark.triangle.fill")
+                                    .foregroundColor(.yellow)
+                                Text("No offerings configured")
+                            }
+                            Text(
+                                """
+                                While offerings are not mandatory, they are the way to 'offer' products \
+                                to your customers on your paywalls.
+                                """
+                            )
+                            .font(.caption)
+                            .foregroundStyle(.secondary)
+                        }
+                    }
+                case let .offeringConfiguration(offerings):
+                    Section("Offering Validation Issues") {
+                        ForEach(offerings, id: \.identifier) { offering in
+                            if offering.status != .passed {
+                                VStack(alignment: .leading, spacing: 8) {
+                                    HStack(alignment: .center, spacing: 8) {
+                                        offering.status.icon.imageScale(.small)
+                                        Text(offering.identifier)
+                                    }
+                                    Text(
+                                        offering.packages.isEmpty ?
+                                        "Offerings must have at least one package" :
+                                        """
+                                        Products \(offering.packages.map { "'\($0.productIdentifier)'" }.formatted()) \
+                                        in the offering have issues, check the products list above for more details.
+                                        """
path: Sources/Support/DebugUI/DebugContentViews.swift
user: login: polpielladev
body: Honestly! ❤️ I can't wait to ship thisssss 🚀💪
created_at: 2025-04-11T09:15:16Z
author_association: COLLABORATOR
diff_hunk: @@ -44,6 +45,63 @@ public final class PurchasesDiagnostics: NSObject {
 
 extension PurchasesDiagnostics {
 
+    /// Enum representing the status of a product in the store
+    public enum ProductStatus {
+        /// Product is configured correctly in App Store Connect
+        case valid
+        /// There was a problem checking the product state in App Store Connect
+        case couldNotCheck
+        /// The product does not exist in App Store Connect
+        case notFound
+        /// The product is in a state that requires action from Apple or the Developer before being ready for production
+        case actionInProgress
path: Sources/Support/PurchasesDiagnostics.swift
user: login: polpielladev
body: ![Gif](https://media2.giphy.com/media/v1.Y2lkPTc5MGI3NjExZHVpNDY4OXVpMnVidGphcW15anZsbTJ3b2sxcWJxbmZlMWgzbzlwZiZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/9PxJYXAuR8QXm/giphy.gif)
created_at: 2025-04-11T09:17:03Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,133 @@
+//
+//  Copyright RevenueCat Inc. All Rights Reserved.
+//
+//  Licensed under the MIT License (the "License");
+//  you may not use this file except in compliance with the License.
+//  You may obtain a copy of the License at
+//
+//      https://opensource.org/licenses/MIT
+//
+//  HealthReportResponse.swift
+//
+//  Created by Pol Piella on 4/8/25.
+
+import Foundation
+
+enum HealthCheckStatus: String {
+    case passed
+    case failed
+    case warning
+    case unknown
+}
+
+enum HealthCheckType: String {
+    case apiKey = "api_key"
+    case sdkVersion = "sdk_version"
+    case bundleId = "bundle_id"
+    case products = "products"
+    case offerings = "offerings"
+    case offeringsProducts = "offerings_products"
+}
+
+enum ProductStatus: String {
+    case valid = "ok"
+    case couldNotCheck = "could_not_check"
+    case notFound = "not_found"
+    case actionInProgress = "action_in_progress"
+    case needsAction = "needs_action"
+    case unknown
+}
+
+struct PackageHealthReport {
+    let identifier: String
+    let title: String?
+    let status: ProductStatus
+    let description: String
+    let productIdentifier: String
+    let productTitle: String?
+}
+
+struct OfferingHealthReport {
+    let identifier: String
+    let packages: [PackageHealthReport]
+    let status: HealthCheckStatus
+}
+
+struct OfferingsCheckDetails {
+    let offerings: [OfferingHealthReport]
+}
+
+struct BundleIdCheckDetails {
+    let sdkBundleId: String
+    let appBundleId: String
+}
+
+enum HealthCheckDetails {
+    case offeringsProducts(OfferingsCheckDetails)
+    case bundleId(BundleIdCheckDetails)
+    case products(ProductsCheckDetails)
+}
+
+struct ProductsCheckDetails {
+    let products: [ProductHealthReport]
+}
+
+struct ProductHealthReport {
+    let identifier: String
+    let title: String?
+    let status: ProductStatus
+    let description: String
+}
+
+struct HealthCheck {
+    let name: HealthCheckType
+    let status: HealthCheckStatus
+    let details: HealthCheckDetails?
+
+    enum CodingKeys: String, CodingKey {
+        case name
+        case status
+        case details
+    }
+
+    init(from decoder: Decoder) throws {
+        let container = try decoder.container(keyedBy: CodingKeys.self)
+        name = try container.decode(HealthCheckType.self, forKey: .name)
+        status = try container.decode(HealthCheckStatus.self, forKey: .status)
+
+        switch name {
+        case .offeringsProducts:
+            details = (try container.decodeIfPresent(OfferingsCheckDetails.self, forKey: .details))
+                .map({ .offeringsProducts($0) })
+        case .bundleId:
+            details = (try container.decodeIfPresent(BundleIdCheckDetails.self, forKey: .details))
+                .map({ .bundleId($0) })
+
+        case .products:
+            details = (try container.decodeIfPresent(ProductsCheckDetails.self, forKey: .details))
+                .map({ .products($0) })
+
+        default:
+            details = nil
+        }
+    }
path: Sources/Networking/Responses/HealthReportResponse.swift
user: login: polpielladev
body: Yeah, so the `details` field is only used to relay extra information about the check itself but whether it is there or not does not determine whether the check has passed or failed.

The reason why we decode in this way is because the details shape varies depending on the type of check the BE has performed. The response will only fail if we are actually returning `details` for a check and they are in the wrong shape 😊
created_at: 2025-04-11T10:19:12Z
author_association: COLLABORATOR
diff_hunk: @@ -1213,6 +1213,11 @@ internal protocol InternalPurchasesType: AnyObject {
     /// - Throws: `PublicError` if request failed.
     func healthRequest(signatureVerification: Bool) async throws
 
+    /// Asks the server for an in-depth report of the SDK's configuration
+    /// - Throws: A `BackendError` if the request fails for an invalid API key or for connectivity issues
+    /// - Returns: A health report with all checks performed in the server and their status
path: Sources/Purchasing/Purchases/PurchasesType.swift
user: login: polpielladev
body: 100% Let me work on the comment a bit more. Unfortunately, since we have to use it in the Sample App, we're going to have to mark it as public, but we can definitely inform users of when they would like to use it.

In terms of running automatically on DEBUG builds, that is for sure the next step, I would like to try validate the approach in a less risky way through the debug menu and while we build the sample app, as we might need some tweaks here and there, but once we are done, let's do it!! 💪
created_at: 2025-04-11T10:21:17Z
author_association: COLLABORATOR
diff_hunk: @@ -1213,6 +1213,11 @@ internal protocol InternalPurchasesType: AnyObject {
     /// - Throws: `PublicError` if request failed.
     func healthRequest(signatureVerification: Bool) async throws
 
+    /// Asks the server for an in-depth report of the SDK's configuration
+    /// - Throws: A `BackendError` if the request fails for an invalid API key or for connectivity issues
+    /// - Returns: A health report with all checks performed in the server and their status
path: Sources/Purchasing/Purchases/PurchasesType.swift
user: login: polpielladev
body: Looking at this type it looks like it is already internal, but I think we are both referring to the methods in the `PurchasesDiagnostics` type.

I have updated the comments now and added a couple of `Important` notes to the documentation:

<img width="658" alt="Screenshot 2025-04-11 at 11 36 22" src="https://github.com/user-attachments/assets/6eb1c9b6-f325-42fe-bba7-a501527e5add" />
created_at: 2025-04-11T10:36:31Z
author_association: COLLABORATOR
diff_hunk: @@ -59,36 +117,74 @@ extension PurchasesDiagnostics {
         /// Failure performing a signed request
         case failedMakingSignedRequest(Swift.Error)
 
+        /// Version of the SDK is not supported
+        case invalidSDKVersion
+
+        /// There are no offerings in project
+        case noOfferings
+
+        /// Offerings are not configured correctly
+        case offeringConfiguration([OfferingConfigurationErrorPayload])
+
+        /// App bundle ID does not match the one set in the dashboard
+        case invalidBundleId(InvalidBundleIdErrorPayload?)
+
+        /// One or more products are not configured correctly
+        case invalidProducts([InvalidProductErrorPayload])
+
+        /// The person is not authorized to make In-App Purchases
+        case notAuthorizedToMakePayments
+
         /// Any other not identifier error. You can check the undelying error for details.
         case unknown(Swift.Error)
 
     }
 
 }
 
-// MARK: - Implementation
-
 extension PurchasesDiagnostics {
+    /// Status of the SDK Health report
+    public enum SDKHealthStatus {
+        /// SDK configuration is valid but might have some non-blocking issues
+        case healthy(warnings: [PurchasesDiagnostics.Error])
+        /// SDK configuration is not valid and has issues that must be resolved
+        case unhealthy(PurchasesDiagnostics.Error)
+    }
 
-    /// Perform tests to ensure SDK is configured correctly.
-    /// - `Throws`: ``PurchasesDiagnostics/Error`` if any step fails
-    @objc(testSDKHealthWithCompletion:)
+    /// Throw an error if the SDK is not configured correctly
     public func testSDKHealth() async throws {
-        do {
-            try await self.unauthenticatedRequest()
-            #if !ENABLE_CUSTOM_ENTITLEMENT_COMPUTATION
-            try await self.authenticatedRequest()
-            #endif
-            try await self.offeringsRequest()
-            try await self.signatureVerification()
-        } catch let error as Error {
-            throw error
-        } catch let error {
-            // Catch every other error to ensure that we only throw `Error`s from here.
-            throw Error.unknown(error)
+        switch await self.healthReport() {
+        case let .unhealthy(error): throw error
+        default: break
         }
     }
 
+    private var canMakePayments: Bool {
+        if #available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) {
+            return AppStore.canMakePayments
+        } else {
+            return SKPaymentQueue.canMakePayments()
+        }
+    }
path: Sources/Support/PurchasesDiagnostics.swift
user: login: polpielladev
body: Oh cool! I completely missed that method, let's definitely fix it in a follow-up PR 🚀 💪 
created_at: 2025-04-11T10:37:17Z
author_association: COLLABORATOR
diff_hunk: @@ -167,11 +263,41 @@ extension PurchasesDiagnostics.Error: CustomNSError {
 
     var localizedDescription: String {
         switch self {
+        case .notAuthorizedToMakePayments: return "The person is not authorized to make payments on this device."
         case let .unknown(error): return "Unknown error: \(error.localizedDescription)"
         case let .failedConnectingToAPI(error): return "Error connecting to API: \(error.localizedDescription)"
         case let .failedFetchingOfferings(error): return "Failed fetching offerings: \(error.localizedDescription)"
         case let .failedMakingSignedRequest(error): return "Failed making signed request: \(error.localizedDescription)"
         case .invalidAPIKey: return "API key is not valid"
+        case .invalidSDKVersion: return "SDK Version is not supported"
path: Sources/Support/PurchasesDiagnostics.swift
user: login: polpielladev
body: Actually, by the looks of things, this is an Android-only error, let me remove it from here 👍 

<img width="720" alt="Screenshot 2025-04-11 at 11 38 41" src="https://github.com/user-attachments/assets/da7a68cb-1b5b-4a47-96b1-7909e64ae48e" />

created_at: 2025-04-11T10:38:24Z
author_association: COLLABORATOR
diff_hunk: @@ -167,11 +263,41 @@ extension PurchasesDiagnostics.Error: CustomNSError {
 
     var localizedDescription: String {
         switch self {
+        case .notAuthorizedToMakePayments: return "The person is not authorized to make payments on this device."
         case let .unknown(error): return "Unknown error: \(error.localizedDescription)"
         case let .failedConnectingToAPI(error): return "Error connecting to API: \(error.localizedDescription)"
         case let .failedFetchingOfferings(error): return "Failed fetching offerings: \(error.localizedDescription)"
         case let .failedMakingSignedRequest(error): return "Failed making signed request: \(error.localizedDescription)"
         case .invalidAPIKey: return "API key is not valid"
+        case .invalidSDKVersion: return "SDK Version is not supported"
path: Sources/Support/PurchasesDiagnostics.swift
user: login: polpielladev
body: Updated! ✅ 
created_at: 2025-04-11T10:40:23Z
author_association: COLLABORATOR
diff_hunk: @@ -167,11 +263,41 @@ extension PurchasesDiagnostics.Error: CustomNSError {
 
     var localizedDescription: String {
         switch self {
+        case .notAuthorizedToMakePayments: return "The person is not authorized to make payments on this device."
         case let .unknown(error): return "Unknown error: \(error.localizedDescription)"
         case let .failedConnectingToAPI(error): return "Error connecting to API: \(error.localizedDescription)"
         case let .failedFetchingOfferings(error): return "Failed fetching offerings: \(error.localizedDescription)"
         case let .failedMakingSignedRequest(error): return "Failed making signed request: \(error.localizedDescription)"
         case .invalidAPIKey: return "API key is not valid"
+        case .invalidSDKVersion: return "SDK Version is not supported"
+        case .noOfferings: return "No offerings configured"
+        case let .offeringConfiguration(payload):
+            guard let offendingOffering = payload.first(where: { $0.status == .failed }) else {
+                return "Default offering is not configured correctly"
+            }
+
+            let offeringIdentifier = offendingOffering.identifier
+            let offendingPackageCount = offendingOffering.packages.filter({ $0.status != .valid }).count
+            let noPackages = "Offering '\(offeringIdentifier)' has no packages"
+            let packagesNotReady = """
+            Offering '\(offeringIdentifier)' uses \(offendingPackageCount) products \
+            that are not ready in App Store Connect.
+            """
+
+            return offendingOffering.packages.isEmpty ? noPackages : packagesNotReady
+        case let .invalidBundleId(payload):
+            guard let payload else {
+                return "Bundle ID in your app does not match the Bundle ID in the RevenueCat dashboard"
+            }
+            let sdkBundleId = payload.sdkBundleId
+            let appBundleId = payload.appBundleId
+            return "Bundle ID in your app '\(sdkBundleId)' does not match the RevenueCat app Bundle ID '\(appBundleId)'"
path: Sources/Support/PurchasesDiagnostics.swift
user: login: polpielladev
body: Yeah tbf I did struggle with the wording of this and reading it back it is incredibly confusing hahaha.

Also, that is a killer feature!! Let me tweak the BE and add a small button for it in the debug view model, be right back hahaha  👀
created_at: 2025-04-11T11:15:27Z
author_association: COLLABORATOR
diff_hunk: @@ -167,11 +263,41 @@ extension PurchasesDiagnostics.Error: CustomNSError {
 
     var localizedDescription: String {
         switch self {
+        case .notAuthorizedToMakePayments: return "The person is not authorized to make payments on this device."
         case let .unknown(error): return "Unknown error: \(error.localizedDescription)"
         case let .failedConnectingToAPI(error): return "Error connecting to API: \(error.localizedDescription)"
         case let .failedFetchingOfferings(error): return "Failed fetching offerings: \(error.localizedDescription)"
         case let .failedMakingSignedRequest(error): return "Failed making signed request: \(error.localizedDescription)"
         case .invalidAPIKey: return "API key is not valid"
+        case .invalidSDKVersion: return "SDK Version is not supported"
+        case .noOfferings: return "No offerings configured"
+        case let .offeringConfiguration(payload):
+            guard let offendingOffering = payload.first(where: { $0.status == .failed }) else {
+                return "Default offering is not configured correctly"
+            }
+
+            let offeringIdentifier = offendingOffering.identifier
+            let offendingPackageCount = offendingOffering.packages.filter({ $0.status != .valid }).count
+            let noPackages = "Offering '\(offeringIdentifier)' has no packages"
+            let packagesNotReady = """
+            Offering '\(offeringIdentifier)' uses \(offendingPackageCount) products \
+            that are not ready in App Store Connect.
+            """
+
+            return offendingOffering.packages.isEmpty ? noPackages : packagesNotReady
+        case let .invalidBundleId(payload):
+            guard let payload else {
+                return "Bundle ID in your app does not match the Bundle ID in the RevenueCat dashboard"
+            }
+            let sdkBundleId = payload.sdkBundleId
+            let appBundleId = payload.appBundleId
+            return "Bundle ID in your app '\(sdkBundleId)' does not match the RevenueCat app Bundle ID '\(appBundleId)'"
path: Sources/Support/PurchasesDiagnostics.swift
user: login: polpielladev
body: Added it!!

![RocketSim_Screenshot_iPhone_16_Pro_6 3_2025-04-11_14 14 16](https://github.com/user-attachments/assets/9d031aa4-b657-4fe5-a750-75ea8f4ce5ee)
created_at: 2025-04-11T13:15:19Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,45 @@
+//
+//  Copyright RevenueCat Inc. All Rights Reserved.
+//
+//  Licensed under the MIT License (the "License");
+//  you may not use this file except in compliance with the License.
+//  You may obtain a copy of the License at
+//
+//      https://opensource.org/licenses/MIT
+//
+//  InvalidProductErrorPayload+StatusMessage.swift
+//
+//  Created by Pol Piella on 4/10/25.
+
+import Foundation
+
+extension PurchasesDiagnostics.InvalidProductErrorPayload {
+    var statusMessage: String {
+        switch self.status {
+        case .valid:
+            return "Available for production purchases."
+        case .couldNotCheck:
+            return self.description
+        case .notFound:
+            return """
+                Product not found in App Store Connect. You need to create a product with identifier: \
+                '\(self.identifier)' in App Store Connect to use it for production purchases.
+                """
+        case .actionInProgress:
+            return """
+                Some process is ongoing and needs to be completed before using this product in production purchases, \
+                controlled either by the developer or by Apple (state: \(self.description))
path: Sources/Support/DebugUI/InvalidProductErrorPayload+StatusMessage.swift
user: login: polpielladev
body: Good point! I have updated the copy and will actually update the App Store Connect mapping we have in the Back End to fix some statuses that might cause confusion.
created_at: 2025-04-11T13:18:27Z
author_association: COLLABORATOR
diff_hunk: @@ -83,8 +83,27 @@ extension DebugViewModel {
     var diagnosticsStatus: String {
         switch self.diagnosticsResult {
         case .loading: return "Loading..."
-        case .loaded: return "Configuration OK"
-        case let .failed(error): return "Error: \(error.localizedDescription)"
+        case let .loaded(healthStatus):
+            switch healthStatus {
+            case .healthy: return "Configuration OK"
+            case .unhealthy: return "Invalid Configuration"
+            }
+        }
+    }
+
+    var diagnosticsExplainer: String? {
+        switch self.diagnosticsResult {
+        case let .loaded(healthStatus):
+            switch healthStatus {
+            case .healthy(warnings: let warnings):
+                return warnings.count > 0 ? """
+                The dashboard configuration is valid, however we encountered some potential issues \
path: Sources/Support/DebugUI/DebugViewModel.swift
user: login: polpielladev
body: Good shout! I have updated the copy now 👍 
created_at: 2025-04-11T13:20:13Z
author_association: COLLABORATOR
diff_hunk: @@ -1213,6 +1213,11 @@ internal protocol InternalPurchasesType: AnyObject {
     /// - Throws: `PublicError` if request failed.
     func healthRequest(signatureVerification: Bool) async throws
 
+    /// Asks the server for an in-depth report of the SDK's configuration
+    /// - Throws: A `BackendError` if the request fails for an invalid API key or for connectivity issues
+    /// - Returns: A health report with all checks performed in the server and their status
path: Sources/Purchasing/Purchases/PurchasesType.swift
user: login: aboedo
body: maybe we could also only leave it defined for debug builds to prevent this getting shipped in a mega app
created_at: 2025-04-11T14:52:26Z
author_association: MEMBER
diff_hunk: @@ -1213,6 +1213,11 @@ internal protocol InternalPurchasesType: AnyObject {
     /// - Throws: `PublicError` if request failed.
     func healthRequest(signatureVerification: Bool) async throws
 
+    /// Asks the server for an in-depth report of the SDK's configuration
+    /// - Throws: A `BackendError` if the request fails for an invalid API key or for connectivity issues
+    /// - Returns: A health report with all checks performed in the server and their status
path: Sources/Purchasing/Purchases/PurchasesType.swift
user: login: aboedo
body: i.e.: wrapping it in `#if DEBUG`

Then we wouldn't even need the second part of that warning - the compiler would prevent it (although it's good to keep it for clarity)
created_at: 2025-04-11T14:53:32Z
author_association: MEMBER
diff_hunk: @@ -167,11 +263,41 @@ extension PurchasesDiagnostics.Error: CustomNSError {
 
     var localizedDescription: String {
         switch self {
+        case .notAuthorizedToMakePayments: return "The person is not authorized to make payments on this device."
         case let .unknown(error): return "Unknown error: \(error.localizedDescription)"
         case let .failedConnectingToAPI(error): return "Error connecting to API: \(error.localizedDescription)"
         case let .failedFetchingOfferings(error): return "Failed fetching offerings: \(error.localizedDescription)"
         case let .failedMakingSignedRequest(error): return "Failed making signed request: \(error.localizedDescription)"
         case .invalidAPIKey: return "API key is not valid"
+        case .invalidSDKVersion: return "SDK Version is not supported"
+        case .noOfferings: return "No offerings configured"
+        case let .offeringConfiguration(payload):
+            guard let offendingOffering = payload.first(where: { $0.status == .failed }) else {
+                return "Default offering is not configured correctly"
+            }
+
+            let offeringIdentifier = offendingOffering.identifier
+            let offendingPackageCount = offendingOffering.packages.filter({ $0.status != .valid }).count
+            let noPackages = "Offering '\(offeringIdentifier)' has no packages"
+            let packagesNotReady = """
+            Offering '\(offeringIdentifier)' uses \(offendingPackageCount) products \
+            that are not ready in App Store Connect.
+            """
+
+            return offendingOffering.packages.isEmpty ? noPackages : packagesNotReady
+        case let .invalidBundleId(payload):
+            guard let payload else {
+                return "Bundle ID in your app does not match the Bundle ID in the RevenueCat dashboard"
+            }
+            let sdkBundleId = payload.sdkBundleId
+            let appBundleId = payload.appBundleId
+            return "Bundle ID in your app '\(sdkBundleId)' does not match the RevenueCat app Bundle ID '\(appBundleId)'"
path: Sources/Support/PurchasesDiagnostics.swift
user: login: aboedo
body: 😻😻😻
created_at: 2025-04-11T14:54:35Z
author_association: MEMBER
diff_hunk: @@ -1213,6 +1213,11 @@ internal protocol InternalPurchasesType: AnyObject {
     /// - Throws: `PublicError` if request failed.
     func healthRequest(signatureVerification: Bool) async throws
 
+    /// Asks the server for an in-depth report of the SDK's configuration
+    /// - Throws: A `BackendError` if the request fails for an invalid API key or for connectivity issues
+    /// - Returns: A health report with all checks performed in the server and their status
path: Sources/Purchasing/Purchases/PurchasesType.swift
user: login: polpielladev
body: Oh yeah good point, done it now. I will leave the `Important` block just in case it is not immediate at first glance if people get failures on prod builds 👍 
created_at: 2025-04-14T11:34:43Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,29 @@
+//
+//  Copyright RevenueCat Inc. All Rights Reserved.
+//
+//  Licensed under the MIT License (the "License");
+//  you may not use this file except in compliance with the License.
+//  You may obtain a copy of the License at
+//
+//      https://opensource.org/licenses/MIT
+//
+//  SDKHealthStatus+Icon.swift
+//
+//  Created by Pol Piella on 4/10/25.
+
+import SwiftUI
+
+@available(iOS 16.0, macOS 13.0, *)
+extension PurchasesDiagnostics.SDKHealthStatus {
+    var icon: some View {
+        switch self {
+        case let .healthy(warnings):
+            Image(systemName: warnings.count > 0 ? "checkmark.circle.badge.questionmark.fill" : "checkmark.circle.fill")
+                .foregroundColor(.green)
+        case .unhealthy:
+            Image(systemName: "exclamationmark.triangle")
path: Sources/Support/DebugUI/SDKHealthStatus+Icon.swift
user: login: ajpallares
body: Not my expertise, but since an `unhealthy` status is actually meaning an error that must be fixed, shouldn't we use here `"xmark.circle.fill"` (the same as `PurchasesDiagnostics.SDKHealthCheckStatus` is using for the `failed` case)
created_at: 2025-04-16T07:31:50Z
author_association: MEMBER
diff_hunk: @@ -59,36 +132,107 @@ extension PurchasesDiagnostics {
         /// Failure performing a signed request
         case failedMakingSignedRequest(Swift.Error)
 
+        /// There are no offerings in project
+        case noOfferings
+
+        /// Offerings are not configured correctly
+        case offeringConfiguration([OfferingDiagnosticsPayload])
+
+        /// App bundle ID does not match the one set in the dashboard
+        case invalidBundleId(InvalidBundleIdErrorPayload?)
+
+        /// One or more products are not configured correctly
+        case invalidProducts([ProductDiagnosticsPayload])
+
+        /// The person is not authorized to make In-App Purchases
+        case notAuthorizedToMakePayments
+
         /// Any other not identifier error. You can check the undelying error for details.
         case unknown(Swift.Error)
 
     }
 
 }
 
-// MARK: - Implementation
-
 extension PurchasesDiagnostics {
+    /// A report that encapsulates the result of the SDK configuration health check.
+    /// Use this to programmatically inspect the SDK's health status after calling `healthReport()`.
+    public struct SDKHealthReport: Sendable {
+        /// The overall status of the SDK's health.
+        public let status: SDKHealthStatus
+        /// The RevenueCat project identifier associated with the current SDK configuration, if available.
+        public let projectId: String?
+        /// The RevenueCat app identifier associated with the current SDK configuration, if available.
+        public let appId: String?
+        /// The report for each of your app's products set up in the RevenueCat website
+        public let products: [ProductDiagnosticsPayload]
+        /// The report for each of your app's offerings set up in the RevenueCat website
+        public let offerings: [OfferingDiagnosticsPayload]
+
+        init(
+            status: SDKHealthStatus,
+            projectId: String? = nil,
+            appId: String? = nil,
+            products: [ProductDiagnosticsPayload] = [],
+            offerings: [OfferingDiagnosticsPayload] = []
+        ) {
+            self.status = status
+            self.projectId = projectId
+            self.appId = appId
+            self.products = products
+            self.offerings = offerings
+        }
+    }
 
-    /// Perform tests to ensure SDK is configured correctly.
-    /// - `Throws`: ``PurchasesDiagnostics/Error`` if any step fails
-    @objc(testSDKHealthWithCompletion:)
+    /// Status of the SDK Health report
+    public enum SDKHealthStatus: Sendable {
+        /// SDK configuration is valid but might have some non-blocking issues
+        case healthy(warnings: [PurchasesDiagnostics.Error])
+        /// SDK configuration is not valid and has issues that must be resolved
+        case unhealthy(PurchasesDiagnostics.Error)
path: Sources/Support/PurchasesDiagnostics.swift
user: login: ajpallares
body: Question here. I see that we show all the `healthy` issues together (using `[PurchasesDiagnostics.Error]`) but for the unhealthy ones, we only show them one at a time. Is there a reason for this? It looks to me like we could have `unhealthy([PurchasesDiagnostics.Error])` to show them all at once.
created_at: 2025-04-16T07:40:43Z
author_association: MEMBER
diff_hunk: @@ -59,36 +132,107 @@ extension PurchasesDiagnostics {
         /// Failure performing a signed request
         case failedMakingSignedRequest(Swift.Error)
 
+        /// There are no offerings in project
+        case noOfferings
+
+        /// Offerings are not configured correctly
+        case offeringConfiguration([OfferingDiagnosticsPayload])
+
+        /// App bundle ID does not match the one set in the dashboard
+        case invalidBundleId(InvalidBundleIdErrorPayload?)
+
+        /// One or more products are not configured correctly
+        case invalidProducts([ProductDiagnosticsPayload])
+
+        /// The person is not authorized to make In-App Purchases
+        case notAuthorizedToMakePayments
+
         /// Any other not identifier error. You can check the undelying error for details.
         case unknown(Swift.Error)
 
     }
 
 }
 
-// MARK: - Implementation
-
 extension PurchasesDiagnostics {
+    /// A report that encapsulates the result of the SDK configuration health check.
+    /// Use this to programmatically inspect the SDK's health status after calling `healthReport()`.
+    public struct SDKHealthReport: Sendable {
+        /// The overall status of the SDK's health.
+        public let status: SDKHealthStatus
+        /// The RevenueCat project identifier associated with the current SDK configuration, if available.
+        public let projectId: String?
+        /// The RevenueCat app identifier associated with the current SDK configuration, if available.
+        public let appId: String?
+        /// The report for each of your app's products set up in the RevenueCat website
+        public let products: [ProductDiagnosticsPayload]
+        /// The report for each of your app's offerings set up in the RevenueCat website
+        public let offerings: [OfferingDiagnosticsPayload]
+
+        init(
+            status: SDKHealthStatus,
+            projectId: String? = nil,
+            appId: String? = nil,
+            products: [ProductDiagnosticsPayload] = [],
+            offerings: [OfferingDiagnosticsPayload] = []
+        ) {
+            self.status = status
+            self.projectId = projectId
+            self.appId = appId
+            self.products = products
+            self.offerings = offerings
+        }
+    }
 
-    /// Perform tests to ensure SDK is configured correctly.
-    /// - `Throws`: ``PurchasesDiagnostics/Error`` if any step fails
-    @objc(testSDKHealthWithCompletion:)
+    /// Status of the SDK Health report
+    public enum SDKHealthStatus: Sendable {
+        /// SDK configuration is valid but might have some non-blocking issues
+        case healthy(warnings: [PurchasesDiagnostics.Error])
+        /// SDK configuration is not valid and has issues that must be resolved
+        case unhealthy(PurchasesDiagnostics.Error)
path: Sources/Support/PurchasesDiagnostics.swift
user: login: polpielladev
body: The reason why we only return one error, as opposed to the healthy with warnings case is that the BE just halts on the first error, so we should only highlight that one and there won't be any other errors at that time.

I wanted to leave warnings out of the `unhealthy` state, just so that people keep focus on the one error that's blocking 👍 
created_at: 2025-04-24T09:35:59Z
author_association: COLLABORATOR
diff_hunk: @@ -59,36 +132,107 @@ extension PurchasesDiagnostics {
         /// Failure performing a signed request
         case failedMakingSignedRequest(Swift.Error)
 
+        /// There are no offerings in project
+        case noOfferings
+
+        /// Offerings are not configured correctly
+        case offeringConfiguration([OfferingDiagnosticsPayload])
+
+        /// App bundle ID does not match the one set in the dashboard
+        case invalidBundleId(InvalidBundleIdErrorPayload?)
+
+        /// One or more products are not configured correctly
+        case invalidProducts([ProductDiagnosticsPayload])
+
+        /// The person is not authorized to make In-App Purchases
+        case notAuthorizedToMakePayments
+
         /// Any other not identifier error. You can check the undelying error for details.
         case unknown(Swift.Error)
 
     }
 
 }
 
-// MARK: - Implementation
-
 extension PurchasesDiagnostics {
+    /// A report that encapsulates the result of the SDK configuration health check.
+    /// Use this to programmatically inspect the SDK's health status after calling `healthReport()`.
+    public struct SDKHealthReport: Sendable {
+        /// The overall status of the SDK's health.
+        public let status: SDKHealthStatus
+        /// The RevenueCat project identifier associated with the current SDK configuration, if available.
+        public let projectId: String?
+        /// The RevenueCat app identifier associated with the current SDK configuration, if available.
+        public let appId: String?
+        /// The report for each of your app's products set up in the RevenueCat website
+        public let products: [ProductDiagnosticsPayload]
+        /// The report for each of your app's offerings set up in the RevenueCat website
+        public let offerings: [OfferingDiagnosticsPayload]
+
+        init(
+            status: SDKHealthStatus,
+            projectId: String? = nil,
+            appId: String? = nil,
+            products: [ProductDiagnosticsPayload] = [],
+            offerings: [OfferingDiagnosticsPayload] = []
+        ) {
+            self.status = status
+            self.projectId = projectId
+            self.appId = appId
+            self.products = products
+            self.offerings = offerings
+        }
+    }
 
-    /// Perform tests to ensure SDK is configured correctly.
-    /// - `Throws`: ``PurchasesDiagnostics/Error`` if any step fails
-    @objc(testSDKHealthWithCompletion:)
+    /// Status of the SDK Health report
+    public enum SDKHealthStatus: Sendable {
+        /// SDK configuration is valid but might have some non-blocking issues
+        case healthy(warnings: [PurchasesDiagnostics.Error])
+        /// SDK configuration is not valid and has issues that must be resolved
+        case unhealthy(PurchasesDiagnostics.Error)
path: Sources/Support/PurchasesDiagnostics.swift
user: login: ajpallares
body: Ohh makes sense. Thanks for the explanation!
created_at: 2025-04-24T09:38:43Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,29 @@
+//
+//  Copyright RevenueCat Inc. All Rights Reserved.
+//
+//  Licensed under the MIT License (the "License");
+//  you may not use this file except in compliance with the License.
+//  You may obtain a copy of the License at
+//
+//      https://opensource.org/licenses/MIT
+//
+//  SDKHealthStatus+Icon.swift
+//
+//  Created by Pol Piella on 4/10/25.
+
+import SwiftUI
+
+@available(iOS 16.0, macOS 13.0, *)
+extension PurchasesDiagnostics.SDKHealthStatus {
+    var icon: some View {
+        switch self {
+        case let .healthy(warnings):
+            Image(systemName: warnings.count > 0 ? "checkmark.circle.badge.questionmark.fill" : "checkmark.circle.fill")
+                .foregroundColor(.green)
+        case .unhealthy:
+            Image(systemName: "exclamationmark.triangle")
path: Sources/Support/DebugUI/SDKHealthStatus+Icon.swift
user: login: polpielladev
body: 100%! Great catch, I ended up using what we had before, but having the x asset makes a lot more sense, on ittt!!
created_at: 2025-04-24T09:39:24Z
author_association: COLLABORATOR

PR comments:
user: login: emerge-tools[bot]
created_at: 2025-04-10T14:10:54Z
body: ## 1 build increased size

| Name | Version | Download | Change | Install | Change | Approval |
| :--- | :---: | :---: | :---: | :---: | :---: | :---: |
| <a href="https://www.emergetools.com/build/be6c3c31-4525-4eae-b519-6bbca6239832?utm_source=github&utm_medium=vcs" target="_blank" rel="noreferrer noopener">RevenueCat</a><br>com.revenuecat.PaywallsTester | 1.0 (1) | 11.7 MB | ⬆️ 129.9 kB (1.13%) | 44.3 MB | ⬆️ 619.5 kB (1.42%) | N/A |


## <a href="https://www.emergetools.com/build/be6c3c31-4525-4eae-b519-6bbca6239832?utm_source=github&utm_medium=vcs" target="_blank" rel="noreferrer noopener">RevenueCat 1.0 (1)</a><br>com.revenuecat.PaywallsTester

<a href="https://www.emergetools.com/compare?head=be6c3c31-4525-4eae-b519-6bbca6239832&base=b96eac57-e3c1-4064-bd55-b7497e413881&sizeOpen=true&utm_source=github&utm_medium=vcs&utm_campaign=compare-size" target="_blank" rel="noreferrer noopener">⚖️ Compare build</a>
<a href="https://www.emergetools.com/compare?head=be6c3c31-4525-4eae-b519-6bbca6239832&base=b96eac57-e3c1-4064-bd55-b7497e413881&perfOpen=true&utm_source=github&utm_medium=vcs&utm_campaign=performance-compare" target="_blank" rel="noreferrer noopener">⏱️ Analyze build performance</a>

**Total install size change:** ⬆️ 619.5 kB (1.42%)
**Total download size change:** ⬆️ 129.9 kB (1.13%)

*Largest size changes*

| Item | Install Size Change |
| :---: | :---: |
| DYLD.String Table | ⬆️ 256.4 kB |
| Code Signature | ⬆️ 16.0 kB |
| DYLD.Exports | ⬆️ 9.4 kB |
| Strings.Unmapped | ⬆️ 2.4 kB |
| PaywallsTester.debug.dylib | ⬆️ 1.3 kB |

<details>
  <summary>View Treemap</summary>
  
  [![Image of diff](https://diff-treemap-images.s3.us-west-1.amazonaws.com/org_LrH6fLXfFJKB/b96eac57-e3c1-4064-bd55-b7497e413881.be6c3c31-4525-4eae-b519-6bbca6239832.png)](https://www.emergetools.com/compare?head=be6c3c31-4525-4eae-b519-6bbca6239832&base=b96eac57-e3c1-4064-bd55-b7497e413881&sizeOpen=true&utm_source=github&utm_medium=vcs)
  
</details>


---

:flying_saucer: Powered by <a href="https://www.emergetools.com/?utm_source=github&utm_medium=vcs" target="_blank" rel="noreferrer noopener">Emerge Tools</a>

Comment trigger: Size diff threshold of 100.00kB exceeded
author_association: NONE
user: login: polpielladev
created_at: 2025-04-11T13:53:50Z
body: @ajpallares I seem to be getting some failures when compiling the unit test targets, any idea why these types can not be found? Could it be Objective-C interop related? Or have I missed something else? 👀 

https://app.circleci.com/pipelines/github/RevenueCat/purchases-ios/26584/workflows/e56b190c-0186-4477-b6be-e8b1d0019066/jobs/310082
author_association: COLLABORATOR
user: login: ajpallares
created_at: 2025-04-11T14:36:01Z
body: > @ajpallares I seem to be getting some failures when compiling the unit test targets, any idea why these types can not be found? Could it be Objective-C interop related? Or have I missed something else? 👀
> 
> https://app.circleci.com/pipelines/github/RevenueCat/purchases-ios/26584/workflows/e56b190c-0186-4477-b6be-e8b1d0019066/jobs/310082

All the tests use the `RevenueCat.xcworkspace`. I deduce you used the Package.swift to develop these changes, right?  That would explain why the new files you created are not referenced in the workspace. 

Currently, the "official" way to develop the SDK is using the Xcode workspace. We definitely need to fix this so that the SDK can be developed directly using the Package.swift. Sorry about that!
author_association: MEMBER
user: login: polpielladev
created_at: 2025-04-11T14:59:41Z
body: > All the tests use the RevenueCat.xcworkspace. I deduce you used the Package.swift to develop these changes, right? That would explain why the new files you created are not referenced in the workspace.
> 
> Currently, the "official" way to develop the SDK is using the Xcode workspace. We definitely need to fix this so that the SDK can be developed directly using the Package.swift. Sorry about that!

Ahhhhh!!! Gotcha!!! Thanks so much, I will fix it now


author_association: COLLABORATOR
user: login: polpielladev
created_at: 2025-04-14T11:58:50Z
body: @aboedo @ajpallares I think I have addressed all of the feedback now and fixed all the CI/CD failures, let me know if you're happy with the changes 😊 🙏 
author_association: COLLABORATOR