> For the complete documentation index, see [llms.txt](https://docs.onside.io/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.onside.io/sdk/products-and-subscriptions/fetching-products.md).

# Fetching Products

Fetch your product catalog from Onside with a products request, read product details, and handle errors.

Before you can sell anything, fetch the product details from Onside using each product's identifier, which you configure in the [Onside Developer Console](https://developer.onside.io).

{% hint style="info" %}
A product identifier in OnsideKit is the **Onside product identifier (slug)** you configure in the console and pass to the request — it is **not** the App Store / StoreKit SKU. You request and match products by this identifier.
{% endhint %}

The flow is: create a request, assign a delegate, retain it, start it, and handle the result.

## Create and start a request

```swift
@MainActor static func makeProductsRequest(productIdentifiers: Set<String>) -> OnsideProductsRequest
```

```swift
import OnsideKit

final class ProductsViewController: UIViewController {

    private var productsRequest: OnsideProductsRequest?
    private var products: [OnsideProduct] = []

    func fetchProducts() {
        let identifiers: Set<String> = [
            "premium_feature",
            "subscription_monthly",
        ]

        let request = Onside.makeProductsRequest(productIdentifiers: identifiers)
        request.delegate = self
        self.productsRequest = request   // retain it for the whole request
        request.start()
    }
}
```

{% hint style="warning" %}
Keep a strong reference to the `OnsideProductsRequest` until it finishes — see [Threading & Object Lifetime](/sdk/core-concepts/threading-and-retention.md). Use `stop()` to cancel an in-flight request.
{% endhint %}

## Handle the response

Conform to `OnsideProductsRequestDelegate`. All methods are `@MainActor`.

```swift
extension ProductsViewController: OnsideProductsRequestDelegate {

    // Success.
    func onsideProductsRequest(
        _ request: OnsideProductsRequest,
        didReceive response: OnsideProductsResponse
    ) {
        self.products = response.products

        if !response.invalidProductIdentifiers.isEmpty {
            print("Unknown identifiers: \(response.invalidProductIdentifiers)")
        }
        if !response.unsupportedProductIdentifiers.isEmpty {
            print("Unavailable for this account: \(response.unsupportedProductIdentifiers)")
        }
        // Refresh your product list UI here.
    }

    // Failure.
    func onsideProductsRequest(
        _ request: OnsideProductsRequest,
        didFailWithError error: OnsideProductsRequestError
    ) {
        print("Failed to fetch products: \(error)")
    }

    // Optional — always called after success or failure. Good for cleanup.
    func onsideProductsRequestDidFinish(_ request: OnsideProductsRequest) {
        self.productsRequest = nil
    }
}
```

Exactly one of `onsideProductsRequest(_:didReceive:)` or `onsideProductsRequest(_:didFailWithError:)` fires per run, always followed by `onsideProductsRequestDidFinish(_:)` (which has a default empty implementation). Calling `start()` while a request is already running is a no-op; the same request object can be re-`start()`ed after it finishes.

### The response object

```swift
struct OnsideProductsResponse {
    var products: [OnsideProduct]
    var invalidProductIdentifiers: [String]
    var unsupportedProductIdentifiers: [String]
}
```

* `products` — the products that resolved successfully.
* `invalidProductIdentifiers` — an **array** of requested identifiers the backend didn't find. This is a partial-success channel: one response can contain both valid products and unknown identifiers. (This is different from the `.invalidProductIdentifier` error, which means the whole request was rejected — see below.)
* `unsupportedProductIdentifiers` — valid products that cannot be offered to the current profile. For an external profile, subscriptions and non-consumables are excluded from `products` and listed here. This is different from an unknown identifier and does not fail the request.

Filtering uses the authenticated profile at response time. A logged-out request can include products that become unsupported after external login, so refresh the catalog whenever the storefront changes. The purchase queue checks these restrictions again before accepting a payment. In local testing, this list is empty. See [External Login](/sdk/core-concepts/external-login.md).

## Read a product

`OnsideProduct` exposes everything you need to build your store UI:

<table><thead><tr><th width="280">Property</th><th>Description</th></tr></thead><tbody><tr><td><code>productIdentifier: String</code></td><td>The Onside identifier (slug) you requested.</td></tr><tr><td><code>localizedTitle: String</code></td><td>Display name for the user's locale.</td></tr><tr><td><code>localizedDescription: String</code></td><td>Display description.</td></tr><tr><td><code>iconUrl: URL?</code></td><td>Product icon, if available.</td></tr><tr><td><code>price: OnsidePrice</code></td><td>Price (<code>value: Double</code>, <code>currencyCode: String</code>).</td></tr><tr><td><code>subscriptionPeriod: OnsidePeriod?</code></td><td>Set for subscriptions only.</td></tr><tr><td><code>subscriptionGroupIdentifier: String?</code></td><td>Subscription group, subscriptions only.</td></tr></tbody></table>

```swift
func configure(with product: OnsideProduct) {
    titleLabel.text = product.localizedTitle
    descriptionLabel.text = product.localizedDescription
    priceLabel.text = format(product.price)
}

func format(_ price: OnsidePrice) -> String {
    let formatter = NumberFormatter()
    formatter.numberStyle = .currency
    formatter.currencyCode = price.currencyCode
    return formatter.string(from: price.value as NSNumber) ?? "\(price.value) \(price.currencyCode)"
}
```

For subscriptions, the billing period is covered in [Subscriptions](/sdk/products-and-subscriptions/subscriptions.md).

## Errors

`onsideProductsRequest(_:didFailWithError:)` delivers an `OnsideProductsRequestError`:

<table><thead><tr><th width="240">Case</th><th>Cause</th><th>Suggested handling</th></tr></thead><tbody><tr><td><code>.connectionError</code></td><td>Network failure.</td><td>Offer a retry.</td></tr><tr><td><code>.serviceUnavailable</code></td><td>Server returned 5xx.</td><td>Retry later.</td></tr><tr><td><code>.appNotRegistered</code></td><td>The app/install isn't recognized by Onside (HTTP 404).</td><td>Check your app registration/configuration.</td></tr><tr><td><code>.invalidProductIdentifier</code></td><td>The request was rejected (HTTP 422).</td><td>Check the identifiers you sent.</td></tr><tr><td><code>.cancelled</code></td><td>The request was cancelled (e.g. <code>stop()</code>).</td><td>Usually ignore.</td></tr><tr><td><code>.internalError</code></td><td>Parsing or other unexpected error.</td><td>Report if persistent.</td></tr></tbody></table>

See the full [Error Reference](/sdk/reference/errors.md).

## Fetching across regions

You can — and should — fetch products before the user logs in, using a best-guess region, then re-fetch once the storefront is known. See [Regions & Storefronts](/sdk/products-and-subscriptions/regions-and-storefronts.md).


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation by asking a question.

Perform an HTTP GET request on the following URL with the `ask` and `goal` query parameters:

```
GET https://docs.onside.io/sdk/products-and-subscriptions/fetching-products.md?ask=<question>&goal=<user_goal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is what the user is ultimately trying to achieve, the reason they need the answer. Sharing it helps GitBook give you a better, more relevant answer. A goal is most helpful when it describes the outcome the user wants rather than restating the question. For example, with `ask=how do I create an API token`, a goal like `automate deployments from our CI pipeline` lets GitBook tailor the answer to that use case.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
