# SendFax.sh on iOS

Swift, URLSession, no SDK. The `sendfax` npm package is TypeScript-only, so a
native iOS app talks to the OpenAPI contract directly. That is a small amount of
code -- the whole API is four endpoints -- and it is the same code a Mac app uses,
so a multiplatform target can share one file. What differs on iOS is *presenting*
Stripe Checkout, *when* you are allowed to poll, and *how* a wallet gets
involved.

Target: iOS 16+, Swift 5.9+, SwiftUI or UIKit.

- Contract: <https://sendfax.sh/openapi.json>
- Docs: <https://sendfax.sh/docs> | quickstart: <https://sendfax.sh/quickstart.md>
- Shared payment framework: [README.md](/guides/index.md)
- macOS twin (same plumbing, desktop idioms): [macos.md](/guides/macos.md)

---

## The two flows

```
FLOW A -- no wallet (Stripe hosted Checkout)
  UIDocumentPicker / PhotosPicker
        |
        +-> POST /api/v1/uploads   (multipart: document, to)
        |     +-> { id, pageCount, amountCents }
        +-> POST /api/v1/checkout  { id }
        |     +-> { url }   hosted Checkout
        +-> SFSafariViewController(url)        [user pays; sheet stays open]
        +-> GET /api/v1/faxes/{id}  every 2s, FOREGROUND ONLY
              +-> delivered | failed | expired

FLOW B -- wallet (pay the 402)
  UIDocumentPicker
        |
        +-> POST /api/v1/faxes     (multipart: document, to)
        |     +-> 402 + x-payment-deposit-*  (address, exact amount, network)
        +-> Reown AppKit --deep link--> wallet app --> USDC transfer on Base
        |                                    +--universal link--> back to you
        +-> POST /api/v1/faxes     (byte-identical, no payment header)
        |     +-> 402 ... retry every 3s ... -> 202 { id, statusUrl }
        +-> GET /api/v1/faxes/{id}  every 2s
              +-> delivered | failed | expired
```

## Which payment path am I in?

One question: **can this app move USDC on Base right now?** A live WalletConnect
session means Flow B. Nothing connected, or a user who prefers a card, means
Flow A. Ship both and choose at runtime; fall back B -> A, never the reverse.

On iOS there is a second consideration the Mac does not have: Flow B requires a
wallet app installed on the same device (or a scan-to-connect flow to a wallet
elsewhere). If `Sign.instance.getSessions()` is empty and the user has no wallet,
Flow A is not a fallback -- it is the product.

---

## Shared plumbing

Identical to [macos.md](/guides/macos.md) -- in a multiplatform target,
put this in a shared file and leave only the presentation layers per-platform.

```swift
import Foundation

enum SendFax {
    static let baseURL = URL(string: "https://sendfax.sh")!
    /// $0.05/page on the agent rail. lib/domain/pricing.ts:15
    static let perPageUSDMicros = 50_000
    /// USDC on Base. lib/payments/deposit.ts:53-55
    static let usdcBase = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
}

/// RFC 9457 problem+json. lib/runtime/index.ts:240-245
struct Problem: Decodable {
    let type: String?; let title: String?; let status: Int?; let detail: String?
}

enum SendFaxError: LocalizedError {
    case unsupportedDestination(String), invalidDestination(String), invalidDocument(String)
    case notFound, paymentRequired(Challenge)
    case api(status: Int, problem: Problem?), transport(Error)
}

/// Mirrors sdk/src/errors.ts:106-124 and lib/runtime/index.ts:259-283.
func sendFaxError(status: Int, data: Data) -> SendFaxError {
    let problem = try? JSONDecoder().decode(Problem.self, from: data)
    let detail = problem?.detail ?? problem?.title ?? "Request failed with status \(status)."
    switch status {
    case 404: return .notFound
    case 400, 413: return .invalidDocument(detail)
    case 422:
        let title = problem?.title ?? ""
        if title == "Unsupported destination" { return .unsupportedDestination(detail) }
        if title.range(of: "destination", options: .caseInsensitive) != nil {
            return .invalidDestination(detail)
        }
        return .invalidDocument(detail)
    default: return .api(status: status, problem: problem)
    }
}

/// Build once, reuse across retries: Flow B resubmits the byte-identical request.
struct MultipartBody {
    let boundary: String
    let data: Data
    var contentType: String { "multipart/form-data; boundary=\(boundary)" }

    init(pdf: Data, filename: String = "document.pdf", to: String) {
        let boundary = "sendfax.\(UUID().uuidString)"
        var body = Data()
        func append(_ s: String) { body.append(s.data(using: .utf8)!) }

        append("--\(boundary)\r\n")
        append("Content-Disposition: form-data; name=\"document\"; filename=\"\(filename)\"\r\n")
        append("Content-Type: application/pdf\r\n\r\n")
        body.append(pdf)
        append("\r\n--\(boundary)\r\n")
        append("Content-Disposition: form-data; name=\"to\"\r\n\r\n")
        append(to)
        append("\r\n--\(boundary)--\r\n")

        self.boundary = boundary
        self.data = body
    }

    func request(url: URL) -> URLRequest {
        var req = URLRequest(url: url)
        req.httpMethod = "POST"
        req.setValue(contentType, forHTTPHeaderField: "Content-Type")
        req.httpBody = data
        return req
    }
}

enum FaxStatus: String, Decodable {
    case awaitingPayment = "awaiting_payment"
    case paid, queued
    case mediaProcessed = "media_processed"
    case sending, delivered, failed, expired
    /// sdk/src/types.ts:27-31
    var isTerminal: Bool { self == .delivered || self == .failed || self == .expired }
}

struct FaxPublic: Decodable {
    let id: String; let status: FaxStatus; let pageCount: Int
    let toMasked: String; let failureReason: String?
    let createdAt: Int; let terminalAt: Int?
}

extension SendFax {
    /// GET /api/v1/faxes/{id} -- free, no auth. The id is an unguessable capability.
    static func status(id: String) async throws -> FaxPublic {
        let (data, response) = try await URLSession.shared.data(
            from: baseURL.appending(path: "api/v1/faxes/\(id)"))
        let http = response as! HTTPURLResponse
        guard http.statusCode == 200 else { throw sendFaxError(status: http.statusCode, data: data) }
        return try JSONDecoder().decode(FaxPublic.self, from: data)
    }
}
```

### Picking the PDF

`PhotosPicker` returns images, not PDFs -- use it only if you intend to render a
photo into a PDF yourself. For a document, use `fileImporter` (SwiftUI) or
`UIDocumentPickerViewController` (UIKit), and read the bytes inside a
security-scoped access window:

```swift
import SwiftUI
import UniformTypeIdentifiers

struct PickerView: View {
    @State private var importing = false
    @State private var pdf: Data?

    var body: some View {
        Button("Choose PDF") { importing = true }
            .fileImporter(isPresented: $importing, allowedContentTypes: [.pdf]) { result in
                guard case .success(let url) = result else { return }
                guard url.startAccessingSecurityScopedResource() else { return }
                defer { url.stopAccessingSecurityScopedResource() }
                pdf = try? Data(contentsOf: url)
            }
    }
}
```

Check the size before uploading: the API rejects anything over 20 MB with `413`
(`lib/domain/pricing.ts:42`, enforced at `web/app/api/v1/faxes/route.ts:65-67`).
Encrypted PDFs are rejected with `422 "Invalid PDF"` -- a common trap for
documents exported from banking apps.

If you want a photo-to-fax path, render the picked images through
`UIGraphicsPDFRenderer` first and send the resulting `Data`. Page count -- and
therefore price -- is whatever the server counts in the PDF you send.

---

<a name="flow-a"></a>

## Flow A -- no wallet: Stripe hosted Checkout

```swift
struct Upload: Decodable {
    let id: String
    let pageCount: Int
    /// Stripe cents, floored at 50. lib/domain/pricing.ts:23
    let amountCents: Int
}

extension SendFax {
    /// POST /api/v1/uploads -- validates, page-counts, prices. Charges nothing.
    static func upload(pdf: Data, to: String, filename: String = "document.pdf")
        async throws -> Upload
    {
        let req = MultipartBody(pdf: pdf, filename: filename, to: to)
            .request(url: baseURL.appending(path: "api/v1/uploads"))
        let (data, response) = try await URLSession.shared.data(for: req)
        let http = response as! HTTPURLResponse
        guard http.statusCode == 200 else { throw sendFaxError(status: http.statusCode, data: data) }
        return try JSONDecoder().decode(Upload.self, from: data)
    }

    /// POST /api/v1/checkout -- hosted Stripe Checkout URL for a prior upload.
    static func checkoutURL(faxId: String) async throws -> URL {
        var req = URLRequest(url: baseURL.appending(path: "api/v1/checkout"))
        req.httpMethod = "POST"
        req.setValue("application/json", forHTTPHeaderField: "Content-Type")
        req.httpBody = try JSONEncoder().encode(["id": faxId])
        let (data, response) = try await URLSession.shared.data(for: req)
        let http = response as! HTTPURLResponse
        guard http.statusCode == 200 else { throw sendFaxError(status: http.statusCode, data: data) }
        struct Session: Decodable { let url: String }
        guard let url = URL(string: try JSONDecoder().decode(Session.self, from: data).url)
        else { throw SendFaxError.api(status: 200, problem: nil) }
        return url
    }
}
```

### Presenting Checkout

`SFSafariViewController` is the right container: it is a real Safari, so Apple
Pay and Stripe Link work, the URL bar shows `checkout.stripe.com`, and the user
can see they are not typing a card into your UI.

```swift
import SafariServices

struct CheckoutSheet: UIViewControllerRepresentable {
    let url: URL
    func makeUIViewController(context: Context) -> SFSafariViewController {
        let config = SFSafariViewController.Configuration()
        config.entersReaderIfAvailable = false
        let vc = SFSafariViewController(url: url, configuration: config)
        vc.dismissButtonStyle = .done
        return vc
    }
    func updateUIViewController(_ vc: SFSafariViewController, context: Context) {}
}
```

**Do not wait for a redirect back into your app.** Checkout's `success_url` is
`https://sendfax.sh/f/{id}?paid=1` (`lib/payments/stripe.ts:139`) -- the SendFax
status page, not a custom scheme of yours. Consequences:

| | `SFSafariViewController` | `ASWebAuthenticationSession` | `UIApplication.open` |
|---|---|---|---|
| Stays in your app | yes | yes (system sheet) | no |
| Apple Pay / Link autofill | yes | yes (non-ephemeral) | yes |
| Completion callback | none -- poll | `callbackURLScheme` **never fires** for us | none |
| Detects dismissal | via delegate | via error result | no |
| Verdict | **default** | works, but its completion handler is a lie here | fine if you want to hand off entirely |

If you use `ASWebAuthenticationSession` for its sheet chrome, ignore both
arguments of its completion handler and keep polling. A dismissal means
"unknown", not "failed" -- the user may have paid and swiped away.

```swift
@MainActor
final class HumanFlowModel: ObservableObject {
    @Published var checkout: URL?
    @Published var status: FaxStatus?
    @Published var priceLabel = ""
    @Published var error: String?

    private var pollTask: Task<Void, Never>?

    func send(pdf: Data, to: String) async {
        do {
            let upload = try await SendFax.upload(pdf: pdf, to: to)
            priceLabel = String(format: "$%.2f for %d page(s)",
                                Double(upload.amountCents) / 100, upload.pageCount)
            checkout = try await SendFax.checkoutURL(faxId: upload.id)   // presents the sheet
            startPolling(id: upload.id)
        } catch let e as SendFaxError {
            error = describe(e)
        } catch { self.error = error.localizedDescription }
    }
}
```

---

## Polling and the background

**Do not try to poll in the background.** iOS gives a suspended app no general
permission to make repeated network calls, and none of the available mechanisms
fits a 30-second wait:

- `BGAppRefreshTask` is scheduled by the system on its own cadence -- minutes to
  hours, sometimes never. It is for opportunistic refresh, not for tracking a
  transaction the user is watching.
- A background `URLSessionConfiguration` is for a single long transfer, not a
  poll loop; each poll would be a separate task with its own arbitrary scheduling.
- `beginBackgroundTask(withName:)` buys roughly 30 seconds of grace when the user
  backgrounds the app. Useful for finishing an in-flight upload, not for waiting
  out delivery.
- Silent push would work, but SendFax has no push infrastructure to send it.

So: **poll in the foreground, stop on background, resume on return.** For anyone
who wants to leave the app, push them to the status URL in Safari -- that page
updates itself and costs you nothing to maintain.

```swift
extension HumanFlowModel {
    func startPolling(id: String) {
        pollTask?.cancel()
        pollTask = Task { [weak self] in
            while !Task.isCancelled {
                guard let fax = try? await SendFax.status(id: id) else {
                    try? await Task.sleep(for: .seconds(4)); continue
                }
                await MainActor.run { self?.status = fax.status }
                if fax.status.isTerminal { return }
                try? await Task.sleep(for: .seconds(2))
            }
        }
    }

    func stopPolling() { pollTask?.cancel() }
}
```

Wire it to the scene phase, and always offer the escape hatch:

```swift
struct FaxProgressView: View {
    @ObservedObject var model: HumanFlowModel
    @Environment(\.scenePhase) private var scenePhase
    let faxId: String

    var body: some View {
        VStack(spacing: 12) {
            Text(model.status?.rawValue ?? "starting...")
            Link("Follow in Safari",
                 destination: URL(string: "https://sendfax.sh/f/\(faxId)")!)
        }
        .onChange(of: scenePhase) { _, phase in
            switch phase {
            case .active:      model.startPolling(id: faxId)
            case .background:  model.stopPolling()
            default:           break
            }
        }
    }
}
```

Nothing is lost by stopping: status is server-side and the id is durable. When
the user comes back, one `GET` catches you up.

---

<a name="flow-b"></a>

## Flow B -- wallet: pay the 402

### Parsing the 402

Two header families arrive. Parse both; act on the deposit one.

```swift
/// The MPP challenge. `recipient` inside it is the SERVICE's static receiving
/// address (lib/runtime/index.ts:185) -- NOT where you send in deposit mode.
struct MppChallenge {
    let id: String?, realm: String?, method: String?, expires: String?
    let amountBaseUnits: String, tokenAddress: String?
    let chainId: Int?, decimals: Int?, recipient: String?
}

/// The wired settlement path. lib/payments/gate.ts:219-234
struct DepositQuote {
    let network: String          // "base"
    let address: String          // per-request, unique -- send HERE
    let token: String            // USDC ERC-20 contract
    let amountDisplay: String    // "0.050000" -- for the UI
    let amountBaseUnits: UInt64  // 50000 -- for the transfer
    let paymentIntent: String    // "pi_..." -- persist this
}

struct Challenge { let mpp: MppChallenge?; let deposit: DepositQuote? }

/// Port of sdk/src/challenge.ts:188-209. Scans key=value pairs rather than
/// splitting on commas, because the base64url `request` param contains them.
func parseAuthParams(_ header: String) -> (scheme: String, params: [String: String]) {
    let trimmed = header.trimmingCharacters(in: .whitespaces)
    guard let space = trimmed.firstIndex(of: " ") else { return (trimmed, [:]) }
    let scheme = String(trimmed[trimmed.startIndex..<space])
    let rest = String(trimmed[trimmed.index(after: space)...])

    let pattern = #"([A-Za-z0-9._-]+)\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^,\s]+))"#
    let regex = try! NSRegularExpression(pattern: pattern)
    var params: [String: String] = [:]
    for m in regex.matches(in: rest, range: NSRange(rest.startIndex..., in: rest)) {
        guard let k = Range(m.range(at: 1), in: rest) else { continue }
        if let q = Range(m.range(at: 2), in: rest) {
            params[rest[k].lowercased()] = String(rest[q])
                .replacingOccurrences(of: #"\\(.)"#, with: "$1", options: .regularExpression)
        } else if let t = Range(m.range(at: 3), in: rest) {
            params[rest[k].lowercased()] = String(rest[t])
        }
    }
    return (scheme, params)
}

func decodeBase64URLJSON(_ value: String) -> [String: Any]? {
    var s = value.replacingOccurrences(of: "-", with: "+")
                 .replacingOccurrences(of: "_", with: "/")
    while s.count % 4 != 0 { s += "=" }
    guard let d = Data(base64Encoded: s),
          let j = try? JSONSerialization.jsonObject(with: d) as? [String: Any] else { return nil }
    return j
}

func parseChallenge(_ http: HTTPURLResponse) -> Challenge {
    var mpp: MppChallenge?
    if let raw = http.value(forHTTPHeaderField: "WWW-Authenticate") {
        let (scheme, params) = parseAuthParams(raw)
        if scheme.lowercased() == "payment" {
            let request = params["request"].flatMap(decodeBase64URLJSON) ?? [:]
            let details = request["methodDetails"] as? [String: Any] ?? [:]
            mpp = MppChallenge(
                id: params["id"], realm: params["realm"], method: params["method"],
                expires: params["expires"],
                amountBaseUnits: request["amount"] as? String ?? "0",
                tokenAddress: request["currency"] as? String,
                chainId: details["chainId"] as? Int,
                decimals: details["decimals"] as? Int,
                recipient: request["recipient"] as? String)
        }
    }

    var deposit: DepositQuote?
    if let address = http.value(forHTTPHeaderField: "x-payment-deposit-address"),
       let micros = http.value(forHTTPHeaderField: "x-payment-deposit-amount-micros")
                        .flatMap(UInt64.init) {
        deposit = DepositQuote(
            network: http.value(forHTTPHeaderField: "x-payment-deposit-network") ?? "base",
            address: address,
            token: http.value(forHTTPHeaderField: "x-payment-deposit-token") ?? SendFax.usdcBase,
            amountDisplay: http.value(forHTTPHeaderField: "x-payment-deposit-amount") ?? "",
            amountBaseUnits: micros,
            paymentIntent: http.value(forHTTPHeaderField: "x-payment-deposit-payment-intent") ?? "")
    }
    return Challenge(mpp: mpp, deposit: deposit)
}
```

Take the amount from `x-payment-deposit-amount-micros` (an integer string), never
from `x-payment-deposit-amount` ("0.050000") via `Double`. Deposit mode matches
on the exact base-unit value (`lib/payments/deposit.ts:16-18`); a float
round-trip is how you end up one unit short with no auto-match.

### Submitting

```swift
enum SendOutcome { case accepted(Receipt), paymentRequired(Challenge) }

struct Receipt: Decodable {
    let id: String; let pageCount: Int; let amountUsdMicros: Int; let statusUrl: String
}

extension SendFax {
    /// POST /api/v1/faxes. web/app/api/v1/faxes/route.ts
    static func submit(_ body: MultipartBody) async throws -> SendOutcome {
        let (data, response) = try await URLSession.shared.data(
            for: body.request(url: baseURL.appending(path: "api/v1/faxes")))
        let http = response as! HTTPURLResponse
        switch http.statusCode {
        case 202: return .accepted(try JSONDecoder().decode(Receipt.self, from: data))
        case 402: return .paymentRequired(parseChallenge(http))
        default:  throw sendFaxError(status: http.statusCode, data: data)
        }
    }
}
```

### Connecting a wallet (Reown AppKit iOS)

Add `https://github.com/reown-com/reown-swift` via SPM. Reown's surface moves
between majors -- treat these as the shape and check the version you pinned.

```swift
import ReownAppKit
import WalletConnectSign

enum Wallet {
    static let base = Blockchain("eip155:8453")!

    static func configure() {
        Networking.configure(projectId: Secrets.reownProjectId,
                             socketFactory: DefaultSocketFactory())
        AppKit.configure(
            projectId: Secrets.reownProjectId,
            metadata: AppMetadata(
                name: "Your App", description: "Send faxes",
                url: "https://yourapp.example",
                icons: ["https://yourapp.example/icon.png"],
                // Lets wallets return the user to your app instead of Safari.
                redirect: try! AppMetadata.Redirect(native: "yourapp://", universal: nil)),
            sessionParams: .init(requiredNamespaces: [
                "eip155": ProposalNamespace(chains: [base],
                                            methods: ["eth_sendTransaction"], events: [])
            ]))
    }

    static var session: Session? { Sign.instance.getSessions().first }
    static var account: String? { session?.namespaces["eip155"]?.accounts.first?.address }
}
```

Two Info.plist entries matter on iOS:

- `CFBundleURLTypes` with your `yourapp://` scheme, so the wallet can bounce the
  user back after they approve.
- `LSApplicationQueriesSchemes` listing the wallet schemes you want AppKit's
  picker to detect as installed (`metamask`, `rainbow`, `trust`, ...). Without
  them, `canOpenURL` returns false and installed wallets look absent.

Signing happens **in the wallet app**, which means your app is backgrounded
mid-flow. Everything after the transfer must survive a round trip: persist the
fax parameters and `quote.paymentIntent` before you deep-link out, and resume the
retry loop in `scenePhase == .active`.

### Building the transfer

ERC-20 `transfer(address,uint256)`: selector `0xa9059cbb`, recipient padded to 32
bytes, amount padded to 32 bytes.

```swift
func erc20TransferCalldata(to address: String, amountBaseUnits: UInt64) -> String {
    let addr = address.lowercased().hasPrefix("0x") ? String(address.dropFirst(2)) : address
    let paddedAddress = String(repeating: "0", count: 64 - addr.count) + addr.lowercased()
    let amountHex = String(amountBaseUnits, radix: 16)
    let paddedAmount = String(repeating: "0", count: 64 - amountHex.count) + amountHex
    return "0xa9059cbb" + paddedAddress + paddedAmount
}

func sendDepositTransfer(_ quote: DepositQuote, from account: String) async throws {
    guard let session = Wallet.session else { throw WalletError.notConnected }
    let tx = ["from": account,
              "to": quote.token,                                  // the USDC contract
              "data": erc20TransferCalldata(to: quote.address,    // the deposit address
                                            amountBaseUnits: quote.amountBaseUnits),
              "value": "0x0"]
    let request = try Request(topic: session.topic, method: "eth_sendTransaction",
                              params: AnyCodable([tx]), chainId: Wallet.base)
    try await Sign.instance.request(params: request)
    // Foreground the wallet so the user can approve.
    if let uri = session.peer.redirect?.native.flatMap(URL.init) {
        await UIApplication.shared.open(uri)
    }
    // The result arrives on Sign.instance.sessionResponsePublisher.
}

enum WalletError: Error { case notConnected, rejected(String) }
```

Enforce your own ceiling before signing -- the amount arrived over the wire:

```swift
func assertAcceptable(_ quote: DepositQuote, expectedPages: Int) throws {
    guard quote.network == "base",
          quote.token.lowercased() == SendFax.usdcBase.lowercased(),
          quote.amountBaseUnits == UInt64(expectedPages * SendFax.perPageUSDMicros),
          quote.amountBaseUnits <= 5_000_000                  // your own $5 ceiling
    else { throw WalletError.rejected("challenge outside expected bounds") }
}
```

### The retry loop

There is no "submit proof" step. The retry *is* the claim: the server polls the
PaymentIntent and answers 402 until Stripe reports `processing` or `succeeded`
(`lib/payments/deposit.ts:82-83`, `lib/payments/gate.ts:299-324`).

```swift
extension SendFax {
    static func claimAfterTransfer(_ body: MultipartBody) async throws -> Receipt {
        let deadline = ContinuousClock.now + .seconds(14 * 60)   // inside the 15-min TTL
        var delay = Duration.seconds(3)
        while ContinuousClock.now < deadline {
            try Task.checkCancellation()
            try await Task.sleep(for: delay)
            if case .accepted(let receipt) = try await submit(body) { return receipt }
            delay = min(delay * 3 / 2, .seconds(15))
        }
        throw SendFaxError.api(status: 402, problem: nil)
    }

    static func sendWithWallet(pdf: Data, to: String, filename: String = "document.pdf")
        async throws -> Receipt
    {
        let body = MultipartBody(pdf: pdf, filename: filename, to: to)   // build once

        // A 422 for an unsupported destination lands here -- before any address
        // is minted and before any money moves.
        guard case .paymentRequired(let challenge) = try await submit(body),
              let quote = challenge.deposit
        else { throw SendFaxError.api(status: 402, problem: nil) }

        try assertAcceptable(quote, expectedPages: 1)     // your real page count
        guard let account = Wallet.account else { throw WalletError.notConnected }

        // Persist before deep-linking out: the wallet backgrounds your app.
        UserDefaults.standard.set(quote.paymentIntent, forKey: "sendfax.pendingPI")
        try await sendDepositTransfer(quote, from: account)

        return try await claimAfterTransfer(body)
    }
}
```

Then hand the receipt to the foreground poll loop from Flow A. The `pdf` and `to`
must be held in memory (or re-readable) for the whole retry window, since every
retry resends them.

---

## App Store considerations

Not legal advice -- check the current
[App Review Guidelines](https://developer.apple.com/app-store/review/guidelines/)
and talk to whoever signs off on your submissions.

**Guideline 3.1.5(a), Goods and Services Outside of the App.** A fax is a
physical-world service: paper comes out of a machine somewhere else. Apps selling
services consumed outside the app are required to use payment methods *other
than* in-app purchase -- so Stripe Checkout is not merely permitted here, it is
the expected mechanism. This is the same category as ride-hailing, food delivery,
and printing services.

**Keep the purchase outside your UI.** Present Checkout in
`SFSafariViewController` or hand off to Safari. Do not collect card details in
your own views, and do not build a UI that reads as an in-app purchase sheet.

**Guideline 3.1.5(b), Cryptocurrencies.** Flow B is a payment made *by the user's
own wallet app*, initiated over WalletConnect. Your app never custodies keys,
never exchanges currency, and never mints anything -- it hands a transaction
request to software the user already installed. That is the well-trodden shape.
If you were to embed a signer and hold keys yourself, you are building a wallet
and the guideline reads differently.

**Describe the price honestly in your listing.** $0.05/page by wallet;
`max($0.50, $0.05 x pages)` by card, because Stripe's minimum charge is 50 cents
(`lib/domain/pricing.ts:16, 23`). A reviewer testing a one-page fax will see
$0.50 in Checkout -- make sure your UI said so first.

---

## Edge cases

**`422` arrives before any payment.** `to` is validated and unsupported countries
rejected at `web/app/api/v1/faxes/route.ts:59-64`; the payment gate does not run
until line 81. Surface the server's `detail` verbatim: *"SendFax currently
delivers to the United States, Canada, Mexico, and Saint Pierre & Miquelon."*
(`lib/domain/fax.ts:84-85`). Same ordering for 413 (over 20 MB) and 422 "Invalid
PDF" (encrypted or unreadable).

**Client-side destination check is a courtesy, not the boundary.** `+1` is the
whole NANP, so a `+1876` Jamaican number passes a prefix check and still fails
carrier-side (`lib/domain/fax.ts:87-97`). If that happens after a Stripe-settled
payment, it is auto-refunded (`lib/payments/refund.ts:1-30`) -- tell the user that
instead of leaving them to wonder.

**The amount must be exact.** Send `quote.amountBaseUnits`. An over- or
under-payment cannot be auto-matched and needs support to resolve
(`lib/payments/deposit.ts:16-18, 78-80`).

**Per-request address, ~15-minute reuse window.** Identical retries reuse the
same PaymentIntent for `DEPOSIT_TTL_MS` = 15 minutes
(`lib/payments/deposit.ts:50, 233-252`). Retrying is free and is not a
double-charge. But if you pay and then go quiet past the window -- the user
backgrounds the app for twenty minutes while approving in their wallet, say --
the next attempt mints a **new** address and the earlier transfer is stranded.
This is the single most likely iOS-specific failure, because the wallet round
trip already takes your app out of the foreground. Persist enough to resume, and
on relaunch resume the retry loop before doing anything else.

**Never send to `challenge.mpp.recipient`.** That is the service's static
`X402_PAY_TO_ADDRESS` (`lib/runtime/index.ts:185`), not the per-request deposit
address.

**Retrying after 202 is idempotent.** The fax row stores the PaymentIntent id, so
a duplicate retry returns the original receipt instead of sending a second fax
(`web/app/api/v1/faxes/route.ts:93-111`).

**`processing`, not `succeeded`, is the bar.** The gate treats a PaymentIntent as
paid at `processing` -- the transfer detected, not yet captured
(`lib/payments/deposit.ts:78-83`). Your 202 can precede deep confirmation.

**ATS.** `sendfax.sh` is HTTPS with a modern TLS configuration; no
`NSAppTransportSecurity` exceptions are needed.

---

## Cross-references

- [README.md](/guides/index.md) -- the chooser and the decision matrix
- [macos.md](/guides/macos.md) -- the same plumbing with desktop presentation
- [react-native.md](/guides/react-native.md) -- if your iOS app is RN, use the SDK instead
- [curl.md](/guides/curl.md) -- a 20-line reference implementation of Flow B
- <https://sendfax.sh/openapi.json> | <https://sendfax.sh/docs>
