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

> ## Agent Instructions
> Use the Payrails Web SDK v6 for new browser integrations. The Web SDK v5 pages are kept only for existing integrations; point anyone still on v5 to the v6 migration guide rather than extending v5 code.
> Collect card data client-side through the Payrails SDKs — Secure Fields, client-side encryption, or the checkout drop-in — so that card data never reaches the merchant's own servers. Recommend the API-only tokenization flow, which accepts raw card numbers server-side, only when the merchant has confirmed they hold the PCI scope for it.
> Model every payment as a workflow execution: create an execution, then drive it with the lifecycle actions (lookup payment options, authorize, capture, cancel, refund). Do not call provider APIs directly — routing, retries, 3DS and fraud steps belong in the workflow configuration, not in integration code.
> Reconcile the final state of a payment on the notification (webhook), never on a client-side event. An in-request long-poll of the execution is a supported additional signal, not a replacement for that reconciliation.
> Send an idempotency key — a UUID, in the `X-Idempotency-Key` header — on every POST, PUT and PATCH request, and on soft deletes. GET requests need none, and hard deletes cannot be idempotent.
> Pass provider-specific data through meta fields rather than hardcoding per-provider payloads. Payrails translates meta fields into each provider's own format.
> Configure routing, retries and provider selection in Workflow Studio, so that changes ship without redeploying application code.

# iOS SDK concepts

> This page explains the mental model behind the Payrails iOS SDK so you can reason about integration decisions confidently.

## The three building blocks

### 1. Session

The **Session** (`Payrails.Session`) is the single source of truth for a checkout. It holds:

* The parsed init payload (amounts, payment method configurations, vault settings)
* The active execution ID
* The holder reference
* Links to backend API actions (BIN lookup, instrument management)

You create a session once per checkout by calling `Payrails.createSession(with:)`. All elements and factory methods draw their configuration from the current session — there is no need to pass it around explicitly.

```
App Backend  ──init payload──►  Payrails.createSession()  ──►  Session (ready)
```

A session does not persist across app launches. When the user starts a new checkout, create a new session.

### 2. Elements

Elements are UIKit views that the SDK manages. You obtain them via factory methods on `Payrails`:

| Factory method | Element type | Description |
| - | - | - |
| `Payrails.createCardForm()` | `Payrails.CardForm` | Card input form (number, expiry, CVV, optional name) |
| `Payrails.createCardPaymentButton(translations:)` | `Payrails.CardPaymentButton` | Submit button for card form or stored instrument |
| `Payrails.createApplePayButton(type:style:)` | `ApplePayElement` | Apple Pay button wrapping `PKPaymentButton` |
| `Payrails.createPayPalButton()` | `PaypalElement` | PayPal checkout button |
| `Payrails.createGenericRedirectButton(translations:paymentMethodCode:)` | `Payrails.GenericRedirectButton` | Button for redirect-based methods (for example, iDEAL) |
| `Payrails.createStoredInstruments()` | `Payrails.StoredInstruments` | List of previously saved payment methods |

All elements are UIView subclasses; add them to your view hierarchy with Auto Layout or frames.

> [!NOTE]
>
> `Payrails.createCardPaymentButton` requires that `createCardForm` has been called first. The form and button are linked automatically.

### 3. Delegates

Delegates are protocols your view controller (or any object) conforms to in order to receive payment lifecycle events. Each element type has a corresponding delegate:

| Element | Delegate protocol |
| - | - |
| `CardPaymentButton` | `PayrailsCardPaymentButtonDelegate` |
| `ApplePayButton` | `PayrailsApplePayButtonDelegate` |
| `PayPalButton` | `PayrailsPayPalButtonDelegate` |
| `GenericRedirectButton` | `GenericRedirectPaymentButtonDelegate` |
| `StoredInstruments` | `PayrailsStoredInstrumentsDelegate` |
| `StoredInstrumentView` | `PayrailsStoredInstrumentViewDelegate` |

Assign the delegate before adding the element to the window.

***

## Payment flows

### Card payment (new card)

```
1. createCardForm()                — card fields appear
2. createCardPaymentButton()       — pay button appears
3. User fills fields, taps button
4. SDK encrypts card data (PayrailsCSE)
5. SDK calls Payrails payment API
6. ┌─ 3DS required ──► presentPayment(_:) called on PaymentPresenter
│                   ──► user completes challenge in the SDK's embedded web view
│                   ──► SDK polls for final status
└─ no 3DS  ──► result delivered immediately
7. delegate callback fires:
   - success  → delegate.onAuthorizeSuccess(_:)
   - failure  → delegate.onAuthorizeFailed(_:failure:)
                (failure.code discriminates: .userCancelled / .authorizationError /
                 .authenticationError / .unknownError)
   - pending  → delegate.onAuthorizePending(_:)
   In parallel, if the execution is left in `authorizePending` (e.g. the user
   abandoned 3DS), the SDK fires the `onSessionExpired` closure supplied at
   `createSession` time to swap the internal config in place — the merchant's
   `Session` reference and cached buttons keep working.
```

### Stored instrument payment

```
1. createStoredInstruments() or createCardPaymentButton(storedInstrument:)
2. User selects instrument, taps button
3. SDK calls Payrails payment API with instrument ID
4. Result via delegate callback
```

### Apple Pay

```
1. createApplePayButton(type:style:)
2. User taps button
3. Apple Pay sheet presented by the SDK
4. User authorises with Face ID / Touch ID
5. SDK processes payment token
6. Result via PayrailsApplePayButtonDelegate
```

### PayPal

```
1. createPayPalButton()
2. User taps button
3. PayPal checkout web flow presented
4. SDK confirms payment and polls for status
5. Result via PayrailsPayPalButtonDelegate
```

### Generic redirect

```
1. createGenericRedirectButton(translations:paymentMethodCode:)
2. User taps button
3. Browser opens redirect URL (SFSafariViewController)
4. User completes flow on payment provider website (camera checks and handover to the provider's app work here)
5. SDK polls the payment status, closes the browser and reports the result
   (closing the browser first reports .userCancelled, unless the payment already completed)
```

***

## Tokenization

Tokenization saves a payment method as a reusable Payrails **instrument** without charging the customer. It returns a stable instrument `id` that identifies the saved method for later use.

This exists to support a **two-step model**: tokenize first, run the resulting instrument through an external decision — a saved-card list, a subscription setup — and only then, if at all, charge it with `executePayment`. Charging is a separate, deliberate action, never a side effect of tokenizing.

Tokenization is **unified across payment methods**. The same `session.tokenize` call handles Apple Pay (the SDK presents the Apple Pay sheet) and cards (the SDK encrypts the embedded card form), selected by the `TokenizationRequest` case, and both return the same `SaveInstrumentResponse`. Adding a method later does not change how the call is made.

Tokenization is distinct from **pay-and-save**: pay-and-save (the `storeInstrument` toggle on a payment) charges the customer and vaults the method in one step, whereas tokenization vaults without any charge.

### How to tokenize a card

Tokenization saves a card to the Payrails vault without triggering an immediate payment. Use this when you want to store a card for future purchases (subscriptions, one-click checkout, etc.).

#### Prerequisites

* An active Payrails session (see [Quick Start](https://docs.payrails.com/docs/sdks/ios.md))
* Vault configuration present in the init payload (`providerConfigId`)
* A `holderReference` in the session config (required to associate the card with a customer)

***

#### How tokenization works

The card form collects and encrypts the card fields client-side using PayrailsCSE. The encrypted blob is sent to the Payrails vault, which returns an instrument ID. Your backend can then use that instrument ID for future payments without ever handling raw card data.

There are two paths:

| Path | When to use |
| - | - |
| **Tokenize only** (`storeInstrument: true`, no immediate payment) | Save the card for later, no charge now |
| **Pay and save** (`showSaveInstrument: true` on card form) | Charge the card and save it simultaneously |

***

#### Path 1: Tokenize without payment

##### Step 1: Create the card form with save toggle

```swift theme={null}
let cardForm = Payrails.createCardForm(
    config: CardFormConfig(
        showNameField: true,
        showSaveInstrument: false  // hide the toggle; saving is always-on here
    )
)
```

##### Step 2: Collect the card and tokenize

The card form collects and encrypts the data. You drive tokenization by calling `tokenize` on the session directly after collecting:

```swift theme={null}
// Implement PaymentPresenter on your view controller
// When the user taps your custom "Save card" button, call collectFields() on the form.
// The form will call its delegate when data is ready.

extension MyViewController: PayrailsCardFormDelegate {
    func cardForm(_ view: Payrails.CardForm, didCollectCardData encryptedData: String) {
        Task {
            do {
                let options = TokenizeOptions(
                    storeInstrument: true,
                    futureUsage: .cardOnFile
                )
                let response = try await session.tokenize(
                    encryptedData: encryptedData,
                    options: options
                )
                // response.instrumentId is your saved card token
                print("Card saved with instrument ID:", response.instrumentId)
            } catch {
                print("Tokenization failed:", error.localizedDescription)
            }
        }
    }

    func cardForm(_ view: Payrails.CardForm, didFailWithError error: Error) {
        print("Card collection error:", error.localizedDescription)
    }
}
```

##### Step 3: Choose a `FutureUsage`

`FutureUsage` tells the vault how the instrument will be used for network-mandated storage rules:

| Value | Meaning |
| - | - |
| `.cardOnFile` | Customer-initiated future payments (default) |
| `.subscription` | Merchant-initiated recurring charges |
| `.unscheduledCardOnFile` | Merchant-initiated, non-recurring (for example, top-up) |

```swift theme={null}
let options = TokenizeOptions(
    storeInstrument: true,
    futureUsage: .subscription
)
```

***

#### Path 2: Pay and save simultaneously

Enable the save toggle on the card form. The user checks the toggle and taps Pay; the SDK performs the payment and vaults the card in one call.

```swift theme={null}
let cardForm = Payrails.createCardForm(
    showSaveInstrument: true  // renders a "Save card" checkbox
)

let payButton = Payrails.createCardPaymentButton(
    translations: CardPaymenButtonTranslations(label: "Pay")
)
payButton.delegate = self
payButton.presenter = self
```

The SDK automatically includes `storeInstrument: true` in the payment request when the toggle is checked. No additional code is needed.

***

#### Using the saved instrument ID

After tokenization, the `SaveInstrumentResponse` contains the instrument ID:

```swift theme={null}
let instrumentId = response.instrumentId

// Pass it to your backend to associate with the customer
// Or use it immediately with the SDK:
let storedInstruments = Payrails.getStoredInstruments(for: .card)
// storedInstruments will include the newly saved card after a session refresh
```

***

#### Verification checklist

* [ ] `providerConfigId` is present in the init payload
* [ ] `holderReference` is present in the init payload
* [ ] `TokenizeOptions.storeInstrument` is `true`
* [ ] `FutureUsage` matches the intended use case
* [ ] Your backend associates the returned instrument ID with the customer record

***

#### Troubleshooting

**"Vault configuration with providerConfigId is required"**
The init payload does not include vault configuration. Ensure your backend passes the correct Payrails environment and merchant configuration.

**"holderReference is required for tokenization"**
The holder reference is missing from the init payload. Contact your Payrails integration engineer to verify the checkout initialization call.

**Card form delegate not being called**
Make sure `cardForm.delegate = self` is set before the user triggers collection.

***

## 3D Secure

When a card payment requires a 3DS challenge, the SDK presents its own view controller, which shows the challenge in an embedded web view. Generic redirects use the same presenter but hand it an `SFSafariViewController`. Your view controller must conform to `PaymentPresenter` and implement `presentPayment(_:)`:

```swift theme={null}
func presentPayment(_ viewController: UIViewController) {
    present(viewController, animated: true)
}
```

The SDK handles the rest: it polls the Payrails API until a final status is received, then calls the appropriate delegate callback.

> Set `payButton.presenter = self` before the user taps the button.

***

## CardPaymentButton modes

`Payrails.CardPaymentButton` operates in two modes:

| Mode | How it's created | Behaviour on tap |
| - | - | - |
| **Card form mode** | `createCardPaymentButton(translations:)` (requires prior `createCardForm()`) | Collects and encrypts card fields, then executes payment |
| **Stored instrument mode** | `createCardPaymentButton(storedInstrument:translations:)` | Executes payment immediately with the stored instrument |

You can switch between modes at runtime using `setStoredInstrument(_:)` and `clearStoredInstrument()`.

***

## Stored instruments and `bindCardPaymentButton`

`Payrails.StoredInstruments` can be bound to a single `CardPaymentButton`:

```swift theme={null}
let storedInstrumentsView = Payrails.createStoredInstruments()
let payButton = Payrails.createCardPaymentButton(translations: translations)

storedInstrumentsView.bindCardPaymentButton(payButton)
```

When a user selects an instrument from the list, the button automatically switches to stored instrument mode. When deselected, it reverts to card form mode. This pattern lets you render one card form and one pay button that handles both flows without conditional logic in your view controller.

***

## Stored card expiry

Each stored card exposes `expiryMonth` and `expiryYear`, read from the session init data. By default the SDK lists expired cards like any other; leaving them out is opt-in, with `includeExpired: false`.

```mermaid theme={null}
flowchart LR
    Backend["Payrails API<br/>instrument with expiry"] --> Init["Session init data"]
    Init --> SDK["Payrails SDK<br/>expiryMonth, expiryYear"]
    SDK --> App["Merchant App<br/>decides: show, flag or hide<br/>(includeExpired: false to hide)"]
```

**An expired card is not an unusable card.** Some issuers accept payments on expired cards, and PSP tokens or network tokens can keep working after the physical card expires. Some merchants rely on this; others carry the dispute liability and prefer to keep expired cards out of the way. Because the right answer differs per merchant, the SDK exposes the data, offers the filter, and leaves the choice to you.

**Status does not reflect expiry.** An expired card can still arrive with status `enabled`, and `getStoredInstruments(for:)` returns it. Don't read the status as a validity check.

**Missing expiry means unknown.** Older instruments may have been saved without expiry, and PayPal instruments never have one. These return `nil`, which is different from expired, so `includeExpired: false` keeps them.

**The expiry rule is calendar-month based.** A card is valid through the last day of its expiry month, in the device's time zone, using the Gregorian calendar whatever calendar the device is set to.

**The values are passed through as sent.** The expiry is the string the server sends, unmodified. Instruments are decoded one at a time: if one instrument is malformed, only that one is skipped and the holder's other saved instruments are still listed.

### Beyond expiry: `details`

`details` gives merchants the same view of a stored instrument as the Web SDK: status, display name, timestamps, holder, fingerprint, future usage, and the card's BIN, suffix, network, expiry and issuer country, or the PayPal email. Keeping the shape identical across platforms means a merchant's logic reads the same on iOS, Android and Web, and the SDK only exposes what the Web SDK already does.

Every property is optional and decoded on its own. That is the same leniency as expiry, for the same reason: no single field may cost the merchant their saved-card list.

### How to handle expired stored cards

Stored cards carry their expiry as `expiryMonth` and `expiryYear`. By default the SDK lists expired cards like any other; this guide shows how to hide them or flag them in your integration.

For why expired cards are listed by default, see [Stored card expiry](#stored-card-expiry) above.

```mermaid theme={null}
flowchart TD
    Init["Session created from init data"] --> Policy{"Your policy"}
    Policy -- "hide" --> Hide["getStoredInstruments(for: .card,<br/>includeExpired: false)"]
    Policy -- "flag" --> List["getStoredInstruments(for: .card)"]
    List --> Check{"expiry month<br/>before current month?"}
    Check -- "no, or expiry unknown" --> Show["Show card, allow payment"]
    Check -- "yes" --> Flag["Show card as expired,<br/>disable its pay button"]
    Hide --> Show
```

#### 1. Hide expired cards

```swift theme={null}
let usableCards = Payrails.getStoredInstruments(for: .card, includeExpired: false)
```

A card is valid through the last day of its expiry month, so a card expiring this month is still returned. Cards without a usable expiry are returned too: unknown is not expired.

Render `usableCards` with your own list and one `Payrails.createCardPaymentButton(storedInstrument:translations:)` per card. The built-in `Payrails.StoredInstruments` view lists every card the session holds and does not take a filter.

#### 2. Or flag them

Keep every card and mark the expired ones. Add the same check the SDK uses:

```swift theme={null}
import Foundation
import Payrails

extension StoredInstrument {
    /// `false` when the instrument has no expiry, so cards without one are never flagged by mistake.
    func isExpired(on date: Date = Date()) -> Bool {
        guard let month = expiryMonth.flatMap(Int.init),
              let year = expiryYear.flatMap(Int.init),
              (1...12).contains(month) else {
            return false
        }
        // Gregorian, not `.current`: a device set to e.g. the Buddhist calendar reports the year as 2569.
        let now = Calendar(identifier: .gregorian).dateComponents([.year, .month], from: date)
        guard let currentYear = now.year, let currentMonth = now.month else { return false }
        return (year, month) < (currentYear, currentMonth)
    }
}
```

Then use it per row:

```swift theme={null}
for card in Payrails.getStoredInstruments(for: .card) {
    let expired = card.isExpired()
    let expiry = [card.expiryMonth, card.expiryYear].compactMap { $0 }.joined(separator: "/")

    let row = SavedCardRow()
    row.title = card.description
    row.subtitle = expired ? "Expired \(expiry)" : "Expires \(expiry)"

    let payButton = Payrails.createCardPaymentButton(
        storedInstrument: card,
        translations: CardPaymenButtonTranslations(label: "Pay")
    )
    payButton.isEnabled = !expired
    row.accessory = payButton
}
```

`SavedCardRow` stands in for your own cell or view.

#### 3. Check before merchant-initiated payments

Subscription renewals and other merchant-initiated payments run from your backend, not the SDK. Apply the same month/year check there before charging a stored card, or the PSP may decline it and your retries will keep failing.

#### Things to watch

**Expiry can be `nil`.** Cards saved before the backend sent expiry, and all PayPal instruments, have no expiry. Treat `nil` as unknown, not as expired; `includeExpired: false` keeps them.

**Expiry is a snapshot.** Values are read from the session init data. If a card's expiry changes (for example through an account updater), re-create the session to see the new value.

#### Reference

| Symbol | Notes |
| - | - |
| `Payrails.getStoredInstruments(for:includeExpired:)` | Defaults to `true`; `false` leaves out cards whose expiry month has passed |
| `Payrails.getStoredInstruments(includeExpired:)` | Same, across card and PayPal; PayPal instruments are kept whatever the flag |
| `StoredInstrument.expiryMonth` | `String?`, two digits; `nil` for PayPal or when absent |
| `StoredInstrument.expiryYear` | `String?`, four digits; `nil` for PayPal or when absent |
| `StoredInstrument.details` | The instrument with the same fields the Web SDK types (status, network, issuer country, …) |

See also [`StoredInstrument`](https://docs.payrails.com/docs/sdks/ios/sdk-api-reference#storedinstrument) in the API reference.

***

## The pre-authorization gate

Every element — card form, card button, Apple Pay, PayPal, generic redirect, stored instrument —
routes its payment through a single `Session` method. That convergence is what makes one
merchant-supplied gate able to cover all of them, present and future, rather than each element
carrying its own hook.

```mermaid theme={null}
flowchart TD
    Element["Any element<br/>(card · PayPal · wallet · redirect)"] --> Session["Payrails Session"]
    Session --> Gate{"onRequestStart<br/>registered?"}
    Gate -- "no" --> Authorize["POST authorize"]
    Gate -- "yes" --> Ask["Merchant handler answers"]
    Ask -- "proceed" --> Authorize
    Ask -- "refuse · timeout" --> Blocked["Stopped<br/>VALIDATION_FAILED"]
    Authorize --> Provider["Provider UI<br/>(sheet · redirect)"]
    Provider --> Confirm["POST confirm"]
```

The gate sits **before** the authorization request and before any provider UI. That position is the
whole point: a merchant revalidating a voucher, wallet balance or loyalty points needs the answer to
arrive while the customer is still on the checkout screen, not after they have approved a payment in
PayPal. Validating when the element is first drawn would answer against a basket the customer can
still change; the further the tap drifts from the check, the staler the answer.

Two design consequences follow.

**Silence is a block, not a pass.** If the handler never answers, the SDK stops the payment after
ten seconds rather than proceeding. A gate whose failure mode is "authorize anyway" gives no
guarantee at all, and the alternative — an element spinning indefinitely because a merchant endpoint
hung — is worse than a refused payment the customer can retry.

**A block is not a decline.** It surfaces as `AuthorizationFailureReason.validationFailed`, distinct
from `authorizationError`, so a merchant's own decision never lands in their analytics as an issuer
rejection. Nothing reached the backend, so there is no payment attempt to reconcile.

**The refusal carries its own reason.** `.refuse(message:)` rather than a bare `false`, because only
the merchant knows *why* they refused — an expired voucher reads differently to a changed basket —
and only they can phrase it for their customer. The message arrives as `AuthorizationFailure.message`,
the same place all other failure text comes from, so it needs no separate channel and no correlation
by `executionId`. The SDK's own timeout diagnostic is deliberately *not* delivered this way: it
describes an integration fault, not something a customer should read.

The handler receives the payment method code and can therefore gate one method while leaving the
rest untouched. It is opt-in: sessions created without it keep a fully synchronous payment path.

### Why not `onPaymentButtonClicked`?

The two hooks look adjacent but answer different questions, and conflating them is the mistake worth
avoiding:

| | `onPaymentButtonClicked` | `onRequestStart` |
| - | - | - |
| Purpose | The customer tapped | May this payment proceed? |
| Returns | `Void` | A `Bool`, via its completion |
| SDK waits for it | No | Yes |
| Can stop the payment | No | Yes |
| Use for | Analytics, observability, spinners | Any check the payment depends on |

`onPaymentButtonClicked` is deliberately a notification. It cannot gate anything, because the SDK
never looks at it and does not wait — work started inside it races the authorization rather than
preceding it. The Web SDK draws the same line between its `buttonClicked` and `requestStart` events.

### How to run a merchant check before authorization

Use `onRequestStart` when your backend has to approve a payment before Payrails authorizes it —
revalidating a voucher, confirming wallet balance, or re-checking loyalty points at the moment the
customer commits.

For why the gate sits where it does, see [The pre-authorization gate](#the-pre-authorization-gate).

> [!WARNING]
>
> `onRequestStart` runs on the customer's device, so it isn't a security boundary: a modified app can skip it. Re-check anything the payment depends on in your backend before you fulfill the order.

```mermaid theme={null}
sequenceDiagram
    participant Customer
    participant App as Merchant App
    participant SDK as Payrails SDK
    participant Backend as Merchant Backend
    participant API as Payrails API

    Customer->>App: Taps pay
    App->>SDK: Element starts the payment
    SDK->>App: onRequestStart(context, completion)
    App->>Backend: POST /pre-payment
    Backend-->>App: valid or invalid
    App-->>SDK: completion(.proceed) or completion(.refuse)
    alt completion(.proceed)
        SDK->>API: POST authorize
        API-->>SDK: Authorized
    else completion(.refuse)
        SDK->>App: onAuthorizeFailed(failure: .validationFailed)
    end
```

#### 1. Register the handler

Supply it at `createSession` time, alongside `onSessionExpired`:

```swift theme={null}
let session = try await Payrails.createSession(
    with: Payrails.Configuration(initData: initData, option: .init(env: .production)),
    onSessionExpired: { completion in
        myBackend.fetchPayrailsInit { completion($0) }
    },
    onRequestStart: { context, completion in
        myBackend.validatePrePayment(executionId: context.executionId) { isValid in
            completion(isValid ? .proceed : .refuse(message: "Your basket is no longer valid."))
        }
    }
)
```

#### 2. Gate only the methods you care about

The handler fires for every payment method on the session. Call `completion(.proceed)` on the
branches you are not gating, or those payments will be blocked too:

```swift theme={null}
onRequestStart: { context, completion in
    guard context.paymentMethodCode == "payPal" else {
        completion(.proceed)
        return
    }
    myBackend.validatePrePayment(executionId: context.executionId) { isValid in
        completion(isValid)
    }
}
```

#### 3. Answer explicitly, even on failure

The SDK waits ten seconds, then blocks the payment and logs a warning. Answer explicitly on your
error paths so the outcome is your decision rather than a timeout:

```swift theme={null}
onRequestStart: { context, completion in
    myBackend.validatePrePayment(executionId: context.executionId) { result in
        switch result {
        case .success(let check):
            completion(check.isValid ? .proceed : .refuse(message: check.reason))
        case .failure:
            // Your service is unreachable. Decide deliberately: .refuse stops the payment,
            // .proceed accepts the risk of an unvalidated basket.
            completion(.refuse(message: "We couldn't confirm your basket. Please try again."))
        }
    }
}
```

#### 4. Handle the block in your delegate

A blocked payment arrives as `onAuthorizeFailed(_:failure:)` with `failure.code == .validationFailed`.
Separate it from a decline — nothing reached the backend, so there is no failed payment to explain:

```swift theme={null}
extension CheckoutViewController: PayrailsPayPalButtonDelegate {
    func onPaymentButtonClicked(_ button: Payrails.PayPalButton) {}
    func onAuthorizeSuccess(_ button: Payrails.PayPalButton) {
        showReceipt()
    }
    func onPaymentSessionExpired(_ button: Payrails.PayPalButton) {
        showRetry()
    }

    func onAuthorizeFailed(_ button: Payrails.PayPalButton, failure: AuthorizationFailure) {
        switch failure.code {
        case .validationFailed:
            // Your own check refused it. failure.message is the reason you passed to
            // .refuse(message:), so you can show it directly instead of mapping a code.
            showBasketChangedMessage(failure.message)
        case .authorizationError:
            showDeclineMessage()         // the issuer refused it
        default:
            showGenericError(failure.message)
        }
    }
}
```

#### Reference

| Context field | Type | Notes |
| - | - | - |
| `executionId` | `String?` | The Payrails execution, when known |
| `paymentMethodCode` | `String` | `"card"`, `"payPal"`, `"applePay"`, … |
| `action` | `Action` | `.authorize` in this version |

| Handler behaviour | Result |
| - | - |
| `completion(.proceed)` | Authorization proceeds |
| `completion(.refuse(message:))` | Stopped as `.validationFailed`, carrying your `message` |
| `completion(.refuse())` | Stopped as `.validationFailed` with a generic description |
| No answer within 10 seconds | Stopped with a generic description, warning logged |
| `completion` called twice | First answer wins |

Only a deliberate `.refuse(message:)` reaches `failure.message`. The timeout logs its diagnostic
instead of surfacing it — it describes an integration fault rather than anything phrased for a
customer.

Refusing never sends an authorization request and returns the element to its idle state.

***

## Co-branded cards

Some cards carry two payment networks at once — a domestic scheme such as `cartesBancaires`, `mada` or `dankort` alongside `visa` or `mastercard`. The same card can be routed either way, and the two routes differ in cost and in which rules apply. EU regulation puts that choice with the shopper rather than the merchant, which is why the SDK surfaces it rather than resolving it silently.

```mermaid theme={null}
flowchart TD
    Typed["Shopper types card number"] --> Lookup["BIN lookup on the first 8 digits"]
    Lookup -- "one scheme" --> Plain["Normal card form, no selector"]
    Lookup -- "two schemes" --> Selector["Card Brand selector appears<br/>one scheme preselected"]
    Selector --> Notify["didChangePreferredScheme"]
    Selector --> Authorize["Authorize carries preferredScheme"]
    Plain --> Authorize
```

The lookup runs on the BIN — the first eight digits — not the full number, so it happens while the shopper is still typing and before anything sensitive is complete.

Three consequences are worth understanding.

**The shopper chooses, and the SDK carries it.** When two schemes resolve, the form shows a selector with one preselected. Whatever is selected travels with the authorization request automatically. Your integration never sets the scheme on a payment; `didChangePreferredScheme` exists so your own UI and analytics can follow along, not so you can forward the value.

**Clearing is a state change too.** If the shopper edits the number so it no longer resolves to a co-branded BIN, the callback fires again with an empty `cardSchemes` and a `nil` `preferredScheme`. That is the signal to undo whatever the earlier call made you draw. An integration that reads `preferredScheme` without checking `cardSchemes` leaves stale UI behind — the most common mistake with this callback.

**It is off unless the backend turns it on.** Two prerequisites, both in the session init response: a `featureConfig.coBrandedCardsRollout` percentage, and a `links.binLookup` entry for the form to call. Absent either, the card form behaves exactly as it did before — no selector, no callback, no BIN lookup. Nothing in your app switches this on.

The delegate method is optional, with a protocol-extension default, so adding it was source-compatible for integrations that implement only the original two `PayrailsCardFormDelegate` callbacks.

### How to support co-branded cards

A co-branded card carries two payment networks — a domestic scheme (`cartesBancaires`, `mada`, `dankort`) alongside an international one (`visa`, `mastercard`). EU regulation requires the shopper to choose which one the payment runs on. The SDK handles the choice; this guide covers what your integration has to do around it.

For why the SDK works this way, see [Co-branded cards](#co-branded-cards) above.

```mermaid theme={null}
sequenceDiagram
    participant Shopper
    participant Form as Payrails Card Form
    participant API as Payrails API
    participant App as Merchant App

    Shopper->>Form: Types card number
    Form->>API: BIN lookup (first 8 digits)
    API-->>Form: Two schemes, one default
    Form->>Shopper: Shows Card Brand selector
    Form->>App: didChangePreferredScheme(change)
    Shopper->>Form: Picks a scheme
    Form->>App: didChangePreferredScheme(change)
    App->>Form: Pay
    Form->>API: Authorize with preferredScheme
```

#### 1. Confirm the feature is switched on

Co-branded support is gated on two things, both outside your app:

* `featureConfig.coBrandedCardsRollout` in the session init response
* a `links.binLookup` entry in the same response

Without both, the card form behaves exactly as before — no selector, no callback. Ask your Payrails contact to enable the rollout for your workspace.

#### 2. Handle the scheme change

The callback is optional: a protocol-extension default means existing `PayrailsCardFormDelegate` implementations keep compiling. Implement it when you want to react to the choice.

```swift theme={null}
extension CheckoutViewController: PayrailsCardFormDelegate {
    func cardForm(_ view: Payrails.CardForm, didChangePreferredScheme change: PreferredSchemeChange) {
        guard !change.cardSchemes.isEmpty else {
            // Not a co-branded card, or the number changed and the state cleared.
            hideSchemeHint()
            return
        }

        // change.preferredScheme is the code that will be sent with the payment.
        showSchemeHint(for: change.preferredScheme)
    }
}
```

It fires in three situations, and the third is the one integrations forget:

| When | `cardSchemes` | `preferredScheme` |
| - | - | - |
| A co-branded BIN resolves | both schemes | the default |
| The shopper picks a different brand | both schemes | their choice |
| The card number changes and co-branded state clears | empty | `nil` |

Treat the third as "this is no longer a co-branded card" and reset any UI you drew from an earlier call. Reading `change.preferredScheme` without checking `cardSchemes` leaves stale hints on screen.

#### 3. Style the selector

The selector renders with the SDK's defaults, so existing styling is unaffected. Override it through `CardFormStyle`:

```swift theme={null}
let style = CardFormStyle(
    cardBrandSelector: CardBrandSelectorStyle(
        // your tokens
    )
)
```

A `nil` value leaves the default in place — see the [styling guide](https://docs.payrails.com/docs/sdks/ios/styling-guide.md) for the full token set.

#### 4. Localize the title

The selector's heading and subheading come from `CardTranslations.Labels`. Supply localized strings rather than relying on the built-in English defaults (`"Card Brand"` and its subtitle):

```swift theme={null}
let translations = CardTranslations(
    labels: CardTranslations.Labels(
        cardBrandSelectorTitle: NSLocalizedString("checkout.card_brand", comment: ""),
        cardBrandSelectorSubtitle: NSLocalizedString("checkout.card_brand_hint", comment: "")
    )
)
```

Either may be left `nil` to keep the SDK default for that line.

#### What you do not have to do

**You do not send the scheme yourself.** The form carries the shopper's choice into the authorization request. `didChangePreferredScheme` is for your UI and analytics — the payment already carries it.

**You do not build the selector.** It is part of the card form.

#### Reference

| Symbol | Notes |
| - | - |
| `cardForm(_:didChangePreferredScheme:)` | Optional delegate callback; default no-op |
| `PreferredSchemeChange` | `preferredScheme: String?`, `cardSchemes: [CardScheme]` |
| `CardScheme` | `code`, `name`, `logoUrl`, `selected` |
| `CardFormStyle.cardBrandSelector` | `CardBrandSelectorStyle?`; `nil` keeps the default |
| `CardTranslations.Labels.cardBrandSelectorTitle` | Selector heading; `nil` uses `"Card Brand"` |
| `CardTranslations.Labels.cardBrandSelectorSubtitle` | Selector subheading; `nil` uses the SDK default |

***

## Security model

* **Card data is never exposed in plaintext.** The SDK encrypts card fields using PayrailsCSE (a Skyflow vault client) before they leave the device.
* **The Session token is short-lived.** Tokens are fetched by your backend and passed to the SDK; they are not stored persistently.
* **Logging is off by default.** The debug overlay and `Payrails.log` output are only visible when explicitly enabled. See [Troubleshooting](https://docs.payrails.com/docs/sdks/ios/troubleshooting.md) for details.

***

## Element lifecycle

Elements hold a weak reference to the session. They are safe to create in `viewDidLoad` and will be deallocated with the view controller. You do not need to manually tear them down.

If the user navigates away during a payment, the in-flight `Task` is cancelled in `deinit` of `CardPaymentButton`, preventing dangling callbacks.

***

## Next steps

* [Quick Start](https://docs.payrails.com/docs/sdks/ios.md) — get to a running integration in 15 minutes
* [iOS SDK API Reference](https://docs.payrails.com/docs/sdks/ios/sdk-api-reference.md) — complete API surface
* [Styling Guide](https://docs.payrails.com/docs/sdks/ios/styling-guide.md) — customise the UI
* [How to handle expired stored cards](#how-to-handle-expired-stored-cards) — hide or flag saved cards past their expiry


## Related topics

- [Android SDK concepts](https://docs.payrails.com/docs/sdks/android/sdk-concepts.md)
- [iOS SDK quick start](https://docs.payrails.com/docs/sdks/ios.md)
- [React Native SDK concepts](https://docs.payrails.com/docs/sdks/react-native/sdk-concepts.md)


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