> ## Documentation Index
> Fetch the complete documentation index at: https://docs.dodopayments.com/llms.txt
> Use this file to discover all available pages before exploring further.

# iOS

> Open Dodo Payments hosted checkout from an iOS app in SFSafariViewController with the official Swift SDK, and get a typed checkout result back from one call.

<Info>
  This page covers the official Dodo Payments iOS checkout SDK for Swift. It opens Dodo Payments hosted checkout in a native browser view and returns a typed result.
</Info>

<CardGroup cols={2}>
  <Card title="Checkout Sessions API" icon="cart-shopping" href="/developer-resources/checkout-session">
    Create the `checkout_url` that this SDK opens, from your backend.
  </Card>

  <Card title="Mobile Integration Guide" icon="mobile" href="/developer-resources/mobile-integration">
    See how this SDK fits into the full mobile payment flow.
  </Card>
</CardGroup>

The iOS SDK opens Dodo Payments hosted checkout in `SFSafariViewController` and returns a typed `CheckoutResult` when the customer finishes or leaves checkout. It holds no API key and contains no networking code, so it never calls the Dodo Payments API. Checkout runs in the browser view. The SDK presents and dismisses that view and reads the result from the return URL.

**Requirements:** iOS 16 or later, and Swift 6.2 or later (the package declares `swift-tools-version: 6.2`). The SDK has no third-party dependencies.

## Installation

<Steps>
  <Step title="Add the Package">
    In Xcode, go to **File → Add Package Dependencies** and enter the package URL:

    ```text theme={null}
    https://github.com/dodopayments/dodopayments-mobile-sdk-ios
    ```

    Select version 1.1.0 or later. [Appearance customization](#appearance-customization) requires 1.1.0.

    To add the package in `Package.swift` instead, add this dependency:

    ```swift Package.swift theme={null}
    .package(url: "https://github.com/dodopayments/dodopayments-mobile-sdk-ios", from: "1.1.0")
    ```

    The library product is `DodoCheckout`.
  </Step>

  <Step title="Register a Callback URL Scheme">
    Register a URL scheme so that iOS routes the checkout's return URL back to your app. Add a URL type to your `Info.plist`:

    ```xml Info.plist theme={null}
    <key>CFBundleURLTypes</key>
    <array>
      <dict>
        <key>CFBundleURLName</key>
        <string>myapp</string>
        <key>CFBundleURLSchemes</key>
        <array>
          <string>myapp</string>
        </array>
      </dict>
    </array>
    ```

    You can also add the URL type in Xcode under **Info → URL Types**.

    Use this scheme in the `returnUrl` you pass to the SDK, for example `myapp://checkout/return`, and set the same URL as the checkout session's `return_url` when your backend creates the session. The SDK matches the return URL on scheme, host, and path. The URL doesn't need to load a real page.
  </Step>
</Steps>

## Usage

`DodoCheckout.start` is an `async` function that runs on the main actor. Pass `checkoutUrl` as a `URL` built from the `checkout_url` your backend returns:

```swift theme={null}
import DodoCheckout

// showSuccess(_:), showFailure(), showExpired(), and reconcileAbandonedSession()
// are your own functions. See Abandoned Sessions for what to reconcile.
let result = try await DodoCheckout.start(
    checkoutUrl: checkoutUrl,   // from your backend's checkout session
    returnUrl: URL(string: "myapp://checkout/return")!,
    onEvent: { event in print(event.name) }  // logging only
)

switch result.status {
case .succeeded: showSuccess(result.paymentId)
case .failed:    showFailure()
case .cancelled: await reconcileAbandonedSession()  // outcome unknown, not a failure
case .pending:   await reconcileAbandonedSession()
case .expired:   showExpired()
}
```

`onEvent` receives `.opened`, `.returnReceived`, and `.closed` events. Their `name` values are `checkout.opened`, `checkout.return_received`, and `checkout.closed`. Use events for logging only, never to decide the outcome.

## Forwarding the Return URL

`SFSafariViewController` can't catch its own return URL, so iOS opens the URL in your app instead. Forward every incoming URL to `DodoCheckout.handleOpenURL(_:)`. In an app without scenes, call it from your app delegate's `application(_:open:options:)`.

<Tabs>
  <Tab title="SwiftUI">
    ```swift theme={null}
    .onOpenURL { url in
        DodoCheckout.handleOpenURL(url)
    }
    ```
  </Tab>

  <Tab title="SceneDelegate">
    ```swift SceneDelegate.swift theme={null}
    func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
        guard let url = URLContexts.first?.url else { return }
        DodoCheckout.handleOpenURL(url)
    }
    ```
  </Tab>
</Tabs>

<Note>
  You can forward every URL. `handleOpenURL` acts only on a URL that matches the `returnUrl` of the checkout in progress, and returns `true` for it. For any other URL, it returns `false`, so handle that URL yourself.
</Note>

## What the Result Means

The SDK builds `CheckoutResult` from the query parameters on the return URL.

<Warning>
  `result.status` is a UI hint, not proof of payment. Confirm every payment from your backend, with the `payment.succeeded` or `subscription.active` webhook.
</Warning>

<ParamField body="status" type="CheckoutStatus" required>
  One of five values:

  * `succeeded`: the return URL has `status=succeeded` (one-time payment) or `status=active` (subscription).
  * `failed`: the payment was declined (`status=failed`).
  * `cancelled`: the customer dismissed the sheet before the return URL arrived. The SDK doesn't know the outcome, and the payment may have succeeded, so don't show a failure screen. Reconcile the [abandoned session](#abandoned-sessions) instead.
  * `pending`: the payment settles later (`status=processing` or any `requires_*` value), or the `status` parameter was missing or unrecognized. Reconcile it like `cancelled`.
  * `expired`: the checkout session expired (`status=expired`).
</ParamField>

<ParamField body="paymentId" type="String?">
  The `payment_id` query parameter, when the return URL includes one. Show it in your UI, but don't use it to grant access. See [Verify the Payment](#verify-the-payment).
</ParamField>

<ParamField body="subscriptionId" type="String?">
  The `subscription_id` query parameter. Set for subscription checkouts.
</ParamField>

<ParamField body="licenseKeys" type="[String]?">
  The `license_key` query parameter. Set when the checkout includes license key products.
</ParamField>

<ParamField body="customerEmail" type="String?">
  The `email` query parameter. Set when checkout captures an email address.
</ParamField>

<ParamField body="raw" type="[String: String]">
  Every query parameter from the return URL, verbatim.
</ParamField>

## Verify the Payment

<CardGroup cols={2}>
  <Card title="Webhooks" icon="webhook" href="/developer-resources/webhooks">
    Dodo Payments calls your backend when a payment succeeds or a subscription activates.
  </Card>

  <Card title="Get Payment Detail" icon="magnifying-glass" href="/api-reference/payments/get-payments-1">
    Look up `paymentId` with your secret key to check its status.
  </Card>
</CardGroup>

Grant access only after one of these confirms the payment. Don't rely on `result.status` alone.

## Appearance Customization

To change the sheet's dismiss button, presentation style, and color scheme, pass a `BrowserCustomization` as `customization` to `start(...)`. Every field is optional. For a `nil` field, the SDK doesn't set that option and iOS applies its own default. The exception is `presentationStyle`, where `nil` means `pageSheet`.

<ParamField body="dismissButtonStyle" type="DismissButtonStyle?">
  Style of the dismiss button: `done`, `close`, or `cancel`. iOS decides whether it renders as a label or an icon.
</ParamField>

<ParamField body="presentationStyle" type="PresentationStyle?">
  `pageSheet` (the default) presents a card that the customer can swipe down to dismiss. `fullScreen` covers the whole screen and has no dismiss gesture.
</ParamField>

<ParamField body="barCollapsingEnabled" type="Bool?">
  Lets the toolbar collapse as the page scrolls. It has a visible effect only when `presentationStyle` is `fullScreen`. With `pageSheet`, the bars stay pinned regardless of this setting.
</ParamField>

<ParamField body="colorScheme" type="ColorScheme?">
  `light` or `dark` forces that appearance regardless of the device's system setting. `system` follows the system setting. This option themes only the native controls around the page. The checkout page's own light or dark mode comes from `customization.theme` on the checkout session, and its colors come from `customization.theme_config`.
</ParamField>

iOS has no toolbar color option. The underlying `SFSafariViewController` tint properties are deprecated as of iOS 26.

```swift theme={null}
let result = try await DodoCheckout.start(
    checkoutUrl: checkoutUrl,
    returnUrl: URL(string: "myapp://checkout/return")!,
    customization: BrowserCustomization(
        dismissButtonStyle: .close,
        presentationStyle: .fullScreen,
        colorScheme: .dark
    )
)
```

## Errors

`start` throws `CheckoutError` only for misuse or a platform failure. Read the reason from `error.code`. A customer who cancels, or a declined payment, is always a result, never a thrown error.

* `invalidCheckoutUrl` (`INVALID_CHECKOUT_URL`): `checkoutUrl` isn't an `https` checkout session URL (path starting with `/session/`) on `checkout.dodopayments.com` or `test.checkout.dodopayments.com`.
* `invalidReturnUrl` (`INVALID_RETURN_URL`): `returnUrl` isn't an absolute URL with a scheme and a host.
* `alreadyInProgress` (`ALREADY_IN_PROGRESS`): another checkout is running. Only one checkout can run at a time.
* `platformError` (`PLATFORM_ERROR`): an unexpected platform failure, such as no view controller to present from.

After a thrown error, check for an abandoned session too. If the sheet didn't confirm that it appeared, the SDK keeps the session on record because checkout may still be open. The exception is `alreadyInProgress`: a record you find then belongs to the checkout that is still running.

## Abandoned Sessions

<Info>
  The SDK records the checkout session when it presents checkout, and clears the record only when checkout ends with `succeeded`, `failed`, or `expired`. The record stays when the app is killed during checkout, and after a `cancelled` or `pending` result. Check for it on the next launch and after every `cancelled` or `pending` result.
</Info>

```swift theme={null}
import DodoCheckout

if let abandoned = DodoCheckout.getAbandonedSession() {
    // Ask your backend for the outcome of abandoned.sessionId, and show it.
    // Clear the record only after the outcome is final:
    DodoCheckout.clearAbandonedSession()
}
```

`abandoned.sessionId` is the checkout session ID, which starts with `cks_`. `abandoned.createdAt` is the `Date` checkout started. Your backend can look up the session with [Get Checkout Session](/api-reference/checkout-sessions/get-checkouts), which returns its `payment_id` and `payment_status`. Until the payment reaches a final status, treat it as pending, not failed.

## Related

<CardGroup cols={2}>
  <Card title="Mobile Integration Guide" icon="mobile" href="/developer-resources/mobile-integration">
    The same contract for Android, React Native, and Flutter.
  </Card>

  <Card title="React Native SDK" icon="react" href="/developer-resources/sdks/react-native">
    Wraps this same Swift core on iOS.
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.