FinanceKit: Reading Financial Transaction Data in iOS Apps


Your users already track their spending in Apple Wallet. With iOS 18, Apple cracked open that vault — not with a REST API and OAuth dance, but with a privacy-first, on-device framework called FinanceKit. One await call and a system permission sheet are all that stand between your app and a user’s real transaction history from Apple Card and participating financial institutions.

This guide covers requesting authorization, querying transactions with predicates and sort descriptors, observing live changes, and building a SwiftUI view that surfaces spending data. We will not cover Apple Pay integration (that is PassKit territory) or server-side receipt validation.

Contents

The Problem

Personal finance apps have historically relied on third-party aggregators — Plaid, Yodlee, MX — that screen-scrape bank credentials or use fragile OAuth connections. The result is a UX that asks users to hand their bank password to a third party, connections that break when banks update their login flows, and a data pipeline that routes sensitive financial information through external servers.

// ❌ The old world: third-party aggregation
final class LegacyTransactionFetcher {
    private let plaidClient: PlaidClient

    func fetchTransactions() async throws -> [Transaction] {
        // 1. User enters bank credentials in a third-party WebView
        let linkToken = try await plaidClient.createLinkToken()
        let publicToken = try await plaidClient.presentLink(token: linkToken)
        let accessToken = try await plaidClient.exchangePublicToken(publicToken)

        // 2. Plaid scrapes the bank on your behalf
        return try await plaidClient.getTransactions(accessToken: accessToken)
        // Data transits Plaid's servers, your server, then the device
    }
}

This architecture introduces latency, privacy concerns, and a dependency on a third-party vendor’s uptime. FinanceKit eliminates the middleman for Apple Card and supported institutions by reading transaction data directly from the device’s Wallet database.

FinanceKit at a Glance

FinanceKit is an iOS 18+ framework that provides read-only access to financial data stored in Apple Wallet. The framework exposes three primary data types:

  • FinanceStore — The entry point. You request authorization and query data through a shared instance.
  • Transaction — A single financial transaction: amount, merchant, date, category, and status.
  • Account — A financial account (Apple Card, savings account) with balance and institution metadata.

All data stays on-device. FinanceKit never sends financial information to your servers or Apple’s — the queries execute against a local database managed by the Wallet app.

Apple Docs: FinanceKit — Apple Developer Documentation

Entitlement and Capability

Before writing any code, you need the FinanceKit entitlement. In Xcode, navigate to your target’s Signing & Capabilities tab, click + Capability, and add FinanceKit. You also need to add a usage description to your Info.plist:

<key>NSFinanceUsageDescription</key>
<string>PixarBudget uses your transaction data to categorize your spending by movie studio.</string>

Warning: Apps distributed outside the App Store (enterprise or TestFlight-only) still require the FinanceKit entitlement. Apple reviews the usage description during App Review — vague descriptions like “to improve your experience” will be rejected.

Requesting Authorization

FinanceKit uses a two-step authorization model. First, you check the current authorization status. Then, if the status is .notDetermined, you request access. The system presents a permission sheet that clearly describes what data your app can read.

import FinanceKit

@available(iOS 18.0, *)
final class PixarBudgetStore {
    private let financeStore = FinanceStore.shared

    /// Checks and requests authorization for transaction data.
    /// Returns `true` if the user granted access.
    func ensureAuthorized() async -> Bool {
        let status = FinanceStore.isDataAvailable(.financialData)

        guard status == .available else {
            // Device does not support FinanceKit (no Wallet data)
            return false
        }

        do {
            let authStatus = try await financeStore.requestAuthorization()
            return authStatus == .authorized
        } catch {
            print("Authorization request failed: \(error.localizedDescription)")
            return false
        }
    }
}

The requestAuthorization() call is async — it suspends until the user taps Allow or Deny on the system sheet. If the user has already granted or denied permission, the call returns immediately without showing a sheet.

Tip: Call FinanceStore.isDataAvailable(.financialData) before requesting authorization. On devices without any configured financial accounts in Wallet, this returns .unavailable, and you can skip the authorization flow entirely and show a helpful empty state instead.

Querying Transactions

Once authorized, you query transactions using a predicate-and-sort-descriptor pattern that will feel familiar if you have worked with Core Data or SwiftData.

Fetching All Transactions

@available(iOS 18.0, *)
extension PixarBudgetStore {
    /// Fetches all transactions, sorted by date descending.
    func fetchAllTransactions() async throws -> [Transaction] {
        let query = TransactionQuery(
            sortDescriptors: [SortDescriptor(\Transaction.transactionDate, order: .reverse)]
        )
        return try await financeStore.transactions(query: query)
    }
}

The transactions(query:) method returns an array of Transaction values. Each transaction exposes properties like merchantName, transactionAmount, transactionDate, creditDebitIndicator, and status.

Filtering with Predicates

For a Pixar budget tracker, you might want to find all transactions at entertainment merchants above a certain amount — say, every time Buzz Lightyear’s family spent more than $50 at a movie theater:

@available(iOS 18.0, *)
extension PixarBudgetStore {
    /// Fetches entertainment transactions above a given threshold.
    func fetchEntertainmentTransactions(
        minimumAmount: Decimal
    ) async throws -> [Transaction] {
        let predicate = #Predicate<Transaction> { transaction in
            transaction.transactionAmount.amount >= minimumAmount
            && transaction.transactionDescription.localizedStandardContains("entertainment")
        }

        let query = TransactionQuery(
            sortDescriptors: [SortDescriptor(\Transaction.transactionDate, order: .reverse)],
            predicate: predicate
        )

        return try await financeStore.transactions(query: query)
    }
}

Fetching Accounts

You can also query the user’s financial accounts to display balances or group transactions by account:

@available(iOS 18.0, *)
extension PixarBudgetStore {
    /// Fetches all financial accounts (Apple Card, savings, etc.)
    func fetchAccounts() async throws -> [Account] {
        let query = AccountQuery(
            sortDescriptors: [SortDescriptor(\Account.displayName, order: .forward)]
        )
        return try await financeStore.accounts(query: query)
    }

    /// Fetches transactions for a specific account.
    func fetchTransactions(for account: Account) async throws -> [Transaction] {
        let predicate = #Predicate<Transaction> { transaction in
            transaction.accountID == account.id
        }
        let query = TransactionQuery(
            sortDescriptors: [SortDescriptor(\Transaction.transactionDate, order: .reverse)],
            predicate: predicate
        )
        return try await financeStore.transactions(query: query)
    }
}

Note: FinanceKit returns transactions from Apple Card and any financial institution that has adopted Apple’s FinanceKit data-sharing protocol. Not all banks participate yet — check fetchAccounts() to see which accounts are available on a given device.

Observing Live Changes

FinanceKit provides a sequence-based observation API for monitoring new transactions in real time. This is ideal for updating a dashboard as the user makes purchases throughout the day:

@available(iOS 18.0, *)
extension PixarBudgetStore {
    /// Observes transaction changes and calls the handler for each batch.
    func observeTransactionChanges() -> some AsyncSequence {
        financeStore.transactionHistory(
            since: .now,
            query: TransactionQuery(
                sortDescriptors: [SortDescriptor(\Transaction.transactionDate, order: .reverse)]
            )
        )
    }
}

The returned async sequence yields FinanceStore.TransactionHistory.Event values — each event contains an array of inserted, updated, or deleted transactions. Consume it with a standard for await loop:

@available(iOS 18.0, *)
func startObserving(store: PixarBudgetStore) async {
    for await event in store.observeTransactionChanges() {
        await MainActor.run {
            applyChanges(event)
        }
    }
}

Because this is an AsyncSequence, cancelling the enclosing Task terminates the observation cleanly — no delegate deregistration or observer removal required. See AsyncSequence and AsyncStream for the full pattern.

Building a Transaction List in SwiftUI

Here is a complete SwiftUI view that ties authorization, fetching, and display together. Imagine Woody is building a personal budget app to track the Toy Story gang’s spending:

import SwiftUI
import FinanceKit

@available(iOS 18.0, *)
struct ToyBoxSpendingView: View {
    @State private var transactions: [Transaction] = []
    @State private var isAuthorized = false
    @State private var isLoading = false
    @State private var errorMessage: String?

    private let store = PixarBudgetStore()

    var body: some View {
        NavigationStack {
            Group {
                if !isAuthorized {
                    authorizationPrompt
                } else if isLoading {
                    ProgressView("Loading transactions...")
                } else if let errorMessage {
                    ContentUnavailableView(
                        "Something went wrong",
                        systemImage: "exclamationmark.triangle",
                        description: Text(errorMessage)
                    )
                } else {
                    transactionList
                }
            }
            .navigationTitle("Toy Box Budget")
            .task { await authorize() }
        }
    }

    private var authorizationPrompt: some View {
        ContentUnavailableView {
            Label("Transaction Access Required", systemImage: "lock.shield")
        } description: {
            Text("Allow Toy Box Budget to read your transaction history.")
        } actions: {
            Button("Grant Access") {
                Task { await authorize() }
            }
            .buttonStyle(.borderedProminent)
        }
    }

    private var transactionList: some View {
        List(transactions, id: \.id) { transaction in
            TransactionRow(transaction: transaction)
        }
        .refreshable { await loadTransactions() }
    }

    private func authorize() async {
        isAuthorized = await store.ensureAuthorized()
        if isAuthorized {
            await loadTransactions()
        }
    }

    private func loadTransactions() async {
        isLoading = true
        errorMessage = nil
        do {
            transactions = try await store.fetchAllTransactions()
        } catch {
            errorMessage = error.localizedDescription
        }
        isLoading = false
    }
}

The TransactionRow component extracts display-ready data from each Transaction:

@available(iOS 18.0, *)
struct TransactionRow: View {
    let transaction: Transaction

    var body: some View {
        HStack {
            VStack(alignment: .leading, spacing: 4) {
                Text(transaction.merchantName ?? "Unknown Merchant")
                    .font(.headline)
                Text(transaction.transactionDate, style: .date)
                    .font(.caption)
                    .foregroundStyle(.secondary)
            }

            Spacer()

            Text(
                transaction.transactionAmount.amount,
                format: .currency(
                    code: transaction.transactionAmount.currencyCode
                )
            )
            .font(.body.monospacedDigit())
            .foregroundStyle(
                transaction.creditDebitIndicator == .credit
                    ? .green
                    : .primary
            )
        }
        .padding(.vertical, 4)
    }
}

Tip: Transaction.transactionAmount is a CurrencyAmount struct with separate amount (Decimal) and currencyCode (String) properties. Always use the currencyCode from the transaction rather than hardcoding a currency — users with international accounts will have transactions in multiple currencies.

Advanced Usage

Grouping Transactions by Category

FinanceKit transactions include a transactionType property that maps to broad categories. You can use Swift’s Dictionary(grouping:by:) to build a category breakdown — perfect for a pie chart in a Pixar-themed budget dashboard:

@available(iOS 18.0, *)
extension PixarBudgetStore {
    /// Groups transactions by type and returns totals per category.
    func spendingByCategory() async throws -> [Transaction.TransactionType: Decimal] {
        let transactions = try await fetchAllTransactions()

        let grouped = Dictionary(grouping: transactions) { $0.transactionType }

        return grouped.mapValues { transactions in
            transactions.reduce(Decimal.zero) { total, transaction in
                total + transaction.transactionAmount.amount
            }
        }
    }
}

Handling the “Denied” State Gracefully

Users can revoke FinanceKit access at any time in Settings. Your app should handle this without crashing:

@available(iOS 18.0, *)
extension PixarBudgetStore {
    /// Attempts to fetch transactions, returning an empty array if access was revoked.
    func fetchTransactionsSafely() async -> [Transaction] {
        do {
            let authStatus = try await financeStore.requestAuthorization()
            guard authStatus == .authorized else {
                return []
            }
            return try await fetchAllTransactions()
        } catch let error as FinanceKit.FinanceKitError {
            switch error {
            case .authorizationDenied:
                // User revoked access — show a re-authorization prompt
                return []
            case .dataNotAvailable:
                // No financial accounts configured
                return []
            default:
                return []
            }
        } catch {
            return []
        }
    }
}

Warning: Do not cache financial transaction data to disk without encryption. FinanceKit deliberately restricts data to on-device, in-memory access. If you need to persist aggregated data (e.g., monthly totals), store only the computed summaries — never raw transaction details — and encrypt them with the Data Protection API.

Date-Range Queries

Most finance features need a date-bounded query. Here is a helper that fetches transactions for the current month:

@available(iOS 18.0, *)
extension PixarBudgetStore {
    /// Fetches transactions from the start of the current month to now.
    func fetchCurrentMonthTransactions() async throws -> [Transaction] {
        let calendar = Calendar.current
        let now = Date.now
        guard let startOfMonth = calendar.date(
            from: calendar.dateComponents([.year, .month], from: now)
        ) else {
            return []
        }

        let predicate = #Predicate<Transaction> { transaction in
            transaction.transactionDate >= startOfMonth
            && transaction.transactionDate <= now
        }

        let query = TransactionQuery(
            sortDescriptors: [SortDescriptor(\Transaction.transactionDate, order: .reverse)],
            predicate: predicate
        )

        return try await financeStore.transactions(query: query)
    }
}

Performance Considerations

FinanceKit queries execute against a local SQLite database managed by the Wallet process. Performance characteristics differ from network-based APIs:

  • Cold queries (first query after app launch) involve an IPC round-trip to the Wallet daemon and may take 100-300ms for a few hundred transactions. Subsequent queries are faster because the Wallet process keeps the database connection warm.
  • Predicate pushdown matters. Predicates passed to TransactionQuery are evaluated inside the Wallet process, not in your app’s memory space. A query with a tight date range predicate fetches only matching rows, while fetching all transactions and filtering client-side loads the entire history into your process.
  • Pagination is not built into the API. If a user has years of Apple Card history (thousands of transactions), a single transactions(query:) call returns everything matching the predicate. Use date-range predicates to batch your queries — fetch one month at a time for infinite-scroll UIs.
  • Memory — each Transaction value is lightweight (a few hundred bytes), but 10,000 transactions still add up. For analytics features that process large datasets, compute aggregates in a detached Task and discard the raw array promptly.

Apple Docs: FinanceStore — FinanceKit

When to Use (and When Not To)

ScenarioRecommendation
Personal finance or budgeting app reading Apple Card dataFinanceKit — purpose-built, on-device, privacy-preserving
Aggregating transactions from non-Apple banksThird-party aggregator (Plaid, MX) — FinanceKit only reads Wallet data
Processing payments or accepting Apple PayPassKit — FinanceKit is read-only
In-app purchase receipt validationStoreKit 2 — different domain entirely
Displaying transaction data on a server dashboardNot FinanceKit — the data never leaves the device by design
Expense reporting with receipt matchingFinanceKit for transactions + VisionKit for receipt OCR

FinanceKit is deliberately narrow. It reads financial data from Apple Wallet — nothing more. If your feature requires writing data, triggering payments, or accessing non-Wallet accounts, you need a different tool. But for the growing category of personal finance apps that want to surface Apple Card spending without a third-party aggregator, FinanceKit is the cleanest path available.

Summary

  • FinanceKit is an iOS 18 framework that provides read-only, on-device access to transaction data from Apple Card and participating financial institutions — no third-party aggregator required.
  • Authorization uses a single requestAuthorization() async call that presents a system permission sheet. Always check isDataAvailable(.financialData) first to handle devices with no Wallet accounts.
  • Queries use TransactionQuery with Swift predicates and sort descriptors. Predicates execute inside the Wallet process, so push filtering as close to the query as possible for best performance.
  • Live observation via transactionHistory(since:query:) returns an AsyncSequence of change events, integrating naturally with structured concurrency and for await loops.
  • Never persist raw transaction data to disk. FinanceKit’s privacy model assumes data stays in memory — store only computed aggregates if you need persistence.

For handling the monetary flows your app generates rather than reads, see StoreKit 2: In-App Purchases and Subscriptions. And if you are securing the financial data your app does store, Passkeys and AuthenticationServices covers the modern credential story.