# SendFax.sh on native macOS

Swift, URLSession, no SDK. The `sendfax` npm package is TypeScript-only, so a
native Mac app talks to the OpenAPI contract directly -- which is a short amount
of code, because the whole API is four endpoints.

Target: macOS 13+, Swift 5.9+, SwiftUI or AppKit. Everything below is plain
Foundation except the optional wallet section.

- 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)

---

## The two flows

```
FLOW A -- no wallet (Stripe hosted Checkout)
  pick PDF --> POST /api/v1/uploads   (multipart: document, to)
                 +-> { id, pageCount, amountCents }
               POST /api/v1/checkout  { id }
                 +-> { url }   cs_live_... hosted Checkout
               NSWorkspace.shared.open(url)      [user pays in their browser]
               GET /api/v1/faxes/{id}  every 2s  <-- the app learns it here,
                 +-> delivered | failed | expired     NOT from a redirect

FLOW B -- wallet (pay the 402)
  pick PDF --> POST /api/v1/faxes     (multipart: document, to)
                 +-> 402 + x-payment-deposit-*  (address, exact amount, network)
               wallet: ERC-20 transfer of the EXACT amount, USDC on Base
               POST /api/v1/faxes     (byte-identical request, 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?

Ask one question: **can this app move USDC on Base right now?** A connected
WalletConnect session, an embedded signer, a hardware wallet -- any of them means
Flow B. Nothing connected, or a user who would rather pay by card, means Flow A.
A well-built Mac app ships both and picks at runtime; the fallback direction is
always B -> A, never the reverse.

---

## Shared plumbing

### Configuration and errors

```swift
import Foundation

enum SendFax {
    static let baseURL = URL(string: "https://sendfax.sh")!

    /// Flat $0.05/page on the agent rail; max($0.50, $0.05 x pages) on Stripe.
    /// lib/domain/pricing.ts:15-24
    static let perPageUSDMicros = 50_000

    /// USDC on Base. lib/payments/deposit.ts:53-55
    static let usdcBase = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
}

/// RFC 9457 problem+json -- every non-2xx except the 402 uses this shape.
/// 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)   // 422, title "Unsupported destination"
    case invalidDestination(String)       // 422, title "Invalid destination"
    case invalidDocument(String)          // 400 / 413 / 422 "Invalid PDF"
    case notFound
    case paymentRequired(Challenge)
    case api(status: Int, problem: Problem?)
    case transport(Error)

    var errorDescription: String? {
        switch self {
        case .unsupportedDestination(let d), .invalidDestination(let d),
             .invalidDocument(let d):                       return d
        case .notFound:                                     return "No fax with that id."
        case .paymentRequired:                              return "Payment required."
        case .api(let status, let problem):
            return problem?.detail ?? "Request failed with status \(status)."
        case .transport(let error):                         return error.localizedDescription
        }
    }
}

/// Map a non-2xx response onto the taxonomy. Mirrors sdk/src/errors.ts:106-124
/// and the server mapping in 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)
    }
}
```

### A multipart builder

Both `POST /api/v1/uploads` and `POST /api/v1/faxes` take the same two fields:
`document` (the PDF) and `to` (E.164). Build the body once and keep it --
Flow B retries the **byte-identical** request, so reuse the same `Data`.

```swift
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")

        append("--\(boundary)\r\n")
        append("Content-Disposition: form-data; name=\"to\"\r\n\r\n")
        append(to)
        append("\r\n")

        append("--\(boundary)--\r\n")

        self.boundary = boundary
        self.data = body
    }

    func request(url: URL, extraHeaders: [String: String] = [:]) -> URLRequest {
        var req = URLRequest(url: url)
        req.httpMethod = "POST"
        req.setValue(contentType, forHTTPHeaderField: "Content-Type")
        for (k, v) in extraHeaders { req.setValue(v, forHTTPHeaderField: k) }
        req.httpBody = data
        return req
    }
}
```

> The boundary is random per `MultipartBody`, but the **payload hash the server
> uses to correlate retries is computed from the PDF bytes, `to`, and the price
> only** -- not the boundary (`lib/payments/deposit.ts:94-108`). Reusing one
> `MultipartBody` across retries is still the right habit: it guarantees the
> bytes cannot drift.

### Client-side destination check

The server rejects unsupported countries with `422` before it ever asks for
money (`web/app/api/v1/faxes/route.ts:62-64`, gate runs at line 81). Mirroring
the check locally is a UX nicety, never the authority:

```swift
/// lib/domain/fax.ts:64-97. `+1` is the whole NANP, so a `+1876` Jamaican number
/// passes here and still fails carrier-side -- the server is the real boundary.
func looksDeliverable(_ e164: String) -> Bool {
    guard e164.range(of: "^\\+[1-9]\\d{7,14}$", options: .regularExpression) != nil
    else { return false }
    return ["+1", "+52", "+508"].contains { e164.hasPrefix($0) }
}
```

### The status model and the poll loop

Both flows end here. `GET /api/v1/faxes/{id}` is free and unauthenticated.

```swift
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          // "**** 0123"
    let failureReason: String?
    let createdAt: Int            // epoch ms
    let terminalAt: Int?
}

extension SendFax {
    static func status(id: String) async throws -> FaxPublic {
        let url = baseURL.appending(path: "api/v1/faxes/\(id)")
        let (data, response) = try await URLSession.shared.data(from: url)
        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)
    }

    /// Poll until terminal. `onStatus` fires on every sample so a SwiftUI view
    /// can show the live progression queued -> media_processed -> sending.
    static func waitForDelivery(
        id: String,
        interval: Duration = .seconds(2),
        timeout: Duration = .seconds(300),
        onStatus: @MainActor (FaxPublic) -> Void = { _ in }
    ) async throws -> FaxPublic {
        let deadline = ContinuousClock.now + timeout
        while true {
            try Task.checkCancellation()
            let fax = try await status(id: id)
            await onStatus(fax)
            if fax.status.isTerminal { return fax }
            guard ContinuousClock.now + interval < deadline else {
                throw SendFaxError.api(status: 0, problem: nil)  // timed out
            }
            try await Task.sleep(for: interval)
        }
    }
}
```

A `failed` fax carries `failureReason`; show it verbatim. A destination-related
failure on a Stripe-settled payment is refunded automatically
(`lib/payments/refund.ts:1-30`) -- say so in the UI rather than making the user ask.

---

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

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

Three calls: upload, checkout, poll. The user pays in their own browser.

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

extension SendFax {
    /// POST /api/v1/uploads -- validates, page-counts, prices. Nothing is charged.
    /// web/app/api/v1/uploads/route.ts
    static func upload(pdf: Data, to: String, filename: String = "document.pdf")
        async throws -> Upload
    {
        let body = MultipartBody(pdf: pdf, filename: filename, to: to)
        let req = body.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.
    /// web/app/api/v1/checkout/route.ts
    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 }
        let session = try JSONDecoder().decode(Session.self, from: data)
        guard let url = URL(string: session.url) else {
            throw SendFaxError.api(status: 200, problem: nil)
        }
        return url
    }
}
```

Drive it from a view model:

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

    func send(pdf: Data, to: String) async {
        do {
            let upload = try await SendFax.upload(pdf: pdf, to: to)
            pageCount = upload.pageCount
            priceLabel = String(format: "$%.2f", Double(upload.amountCents) / 100)

            let url = try await SendFax.checkoutURL(faxId: upload.id)
            NSWorkspace.shared.open(url)         // user's default browser

            // The app is NOT told when payment lands. Poll for it.
            let fax = try await SendFax.waitForDelivery(id: upload.id) { [weak self] f in
                self?.status = f.status
            }
            status = fax.status
        } catch {
            self.error = (error as? SendFaxError)?.errorDescription
                ?? error.localizedDescription
        }
    }
}
```

### `NSWorkspace.open` vs `ASWebAuthenticationSession`

Both work. The trade-off is not about which can display Stripe -- it is about
**how your app learns the payment happened**, and the answer is the same either
way: it polls.

Checkout's `success_url` is `https://sendfax.sh/f/{id}?paid=1`
(`lib/payments/stripe.ts:139`). It redirects to the *SendFax status page*, not to
a custom URL scheme of yours. `ASWebAuthenticationSession` completes only when
the browser navigates to the `callbackURLScheme` you registered, which will never
happen -- the session would just sit on the status page until the user dismisses
it, and your completion handler would report a user-cancelled error even though
payment succeeded.

So:

| | `NSWorkspace.shared.open` | `ASWebAuthenticationSession` |
|---|---|---|
| Presentation | user's default browser, separate app | in-app sheet, ephemeral session |
| Session cookies | shares Safari/Chrome login, so Stripe Link autofills | isolated unless you pass `prefersEphemeralWebBrowserSession = false` |
| Completion signal | none -- you poll | **also none for us** -- `callbackURLScheme` never fires |
| Cancel detection | none | yes, if the user dismisses the sheet |
| Recommendation | **default** | use if you want the sheet UX; still poll, and treat a dismissal as "unknown", not "failed" |

If you use the sheet anyway, keep polling regardless of its outcome:

```swift
import AuthenticationServices

// The callback scheme is a formality: our success_url lands on sendfax.sh/f/{id},
// so this session ends by dismissal, never by redirect. Payment state comes from
// GET /api/v1/faxes/{id} either way.
let session = ASWebAuthenticationSession(
    url: checkoutURL,
    callbackURLScheme: "yourapp"
) { _, _ in
    // Ignore both arguments. Do not surface a cancellation as a failure.
}
session.presentationContextProvider = presenter
session.prefersEphemeralWebBrowserSession = false   // let Stripe Link autofill
session.start()
```

The status page at `https://sendfax.sh/f/{id}` is also a perfectly good place to
leave the user. Offering "Open status page" alongside your own live UI costs one
line and covers the case where they quit your app mid-flight.

---

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

## Flow B -- wallet: pay the 402

### Parsing the 402

The challenge arrives in two header families. Parse both; act on the deposit one.

```swift
/// The MPP challenge (WWW-Authenticate: Payment ...). Parsed for display,
/// diagnostics, and MPP-native wallets. sdk/src/challenge.ts:131-157
struct MppChallenge {
    let id: String?
    let realm: String?
    let method: String?          // "evm"
    let expires: String?         // ISO 8601
    let amountBaseUnits: String  // "50000" = $0.05 of 6-decimal USDC
    let tokenAddress: String?
    let chainId: Int?
    let decimals: Int?
    /// The SERVICE's static receiving address -- NOT the deposit address.
    /// lib/runtime/index.ts:185. Do not send here in deposit mode.
    let recipient: String?
}

/// The wired settlement path. lib/payments/gate.ts:219-234
struct DepositQuote {
    let mode: String             // "stripe-deposit"
    let network: String          // "base"
    let address: String          // per-request, unique
    let token: String            // USDC ERC-20 contract
    let amountDisplay: String    // "0.050000"
    let amountBaseUnits: UInt64  // 50000 -- use THIS, not the display string
    let paymentIntent: String    // "pi_..."
    let instructions: String?
}

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

RFC 7235 auth-param parsing. The `request` parameter is base64url JSON and can
contain characters that naive comma-splitting mangles, so scan for
`key=value` pairs instead of splitting the string:

```swift
/// Split `Payment id="x", realm="y", request=abc` into scheme + params.
/// Port of sdk/src/challenge.ts:188-209.
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)...])

    // key = ("quoted, with commas" | token68); token68 excludes comma and space,
    // so any comma outside quotes separates parameters.
    let pattern = #"([A-Za-z0-9._-]+)\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^,\s]+))"#
    let regex = try! NSRegularExpression(pattern: pattern)
    var params: [String: String] = [:]

    for match in regex.matches(in: rest, range: NSRange(rest.startIndex..., in: rest)) {
        guard let keyRange = Range(match.range(at: 1), in: rest) else { continue }
        let key = rest[keyRange].lowercased()
        if let q = Range(match.range(at: 2), in: rest) {
            params[key] = String(rest[q]).replacingOccurrences(
                of: #"\\(.)"#, with: "$1", options: .regularExpression)
        } else if let t = Range(match.range(at: 3), in: rest) {
            params[key] = String(rest[t])
        }
    }
    return (scheme, params)
}

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

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(
            mode: http.value(forHTTPHeaderField: "x-payment-deposit-mode") ?? "stripe-deposit",
            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") ?? "",
            instructions: http.value(forHTTPHeaderField: "x-payment-deposit-instructions")
        )
    }

    return Challenge(mpp: mpp, deposit: deposit)
}
```

`amountBaseUnits` comes from `x-payment-deposit-amount-micros` -- an integer
string. Never parse `x-payment-deposit-amount` ("0.050000") into a `Double` and
multiply by a million; deposit mode matches on the exact base-unit value
(`lib/payments/deposit.ts:16-18`) and a float round-trip is how you end up one
unit short with no way to auto-match.

### The send call

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

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

extension SendFax {
    /// POST /api/v1/faxes. 402 on the first (unpaid) call; 202 once Stripe has
    /// seen the transfer. web/app/api/v1/faxes/route.ts
    static func submit(_ body: MultipartBody) async throws -> SendOutcome {
        let req = body.request(url: baseURL.appending(path: "api/v1/faxes"))
        let (data, response) = try await URLSession.shared.data(for: req)
        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)

Add the Swift package `https://github.com/reown-com/reown-swift` and configure
once at launch. Reown's API surface moves between majors -- treat the calls below
as the shape, and check the version you pinned.

```swift
import ReownAppKit
import WalletConnectSign

enum Wallet {
    static let baseChain = Blockchain("eip155:8453")!   // Base mainnet

    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"]
            ),
            sessionParams: .init(
                requiredNamespaces: [
                    "eip155": ProposalNamespace(
                        chains: [baseChain],
                        methods: ["eth_sendTransaction"],
                        events: []
                    )
                ]
            )
        )
    }

    /// The connected session, if any. Nil means Flow A.
    static var session: Session? { Sign.instance.getSessions().first }

    static var account: String? {
        session?.namespaces["eip155"]?.accounts.first?.address
    }
}
```

### Building the transfer

An ERC-20 `transfer(address,uint256)` call: 4-byte selector `0xa9059cbb`, then
the recipient left-padded to 32 bytes, then the amount left-padded to 32 bytes.
No ABI library needed for one function.

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

/// Ask the connected wallet to send the USDC transfer. Returns the tx hash.
/// The wallet fills gas and nonce; we only specify `to` (the token contract),
/// `data`, and `value: 0x0`.
func sendDepositTransfer(_ quote: DepositQuote, from account: String) async throws -> String {
    guard let session = Wallet.session else { throw SendFaxError.transport(WalletError.notConnected) }

    let tx: [String: String] = [
        "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.baseChain
    )
    try await Sign.instance.request(params: request)

    // Reown delivers the result asynchronously on `sessionResponsePublisher`.
    // Bridge it to async/await, keyed on the request id.
    return try await Wallet.awaitResponse(id: request.id)
}

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

```swift
import Combine

extension Wallet {
    private static var cancellables = Set<AnyCancellable>()

    /// One-shot bridge from the response publisher to async/await.
    static func awaitResponse(id: RPCID) async throws -> String {
        try await withCheckedThrowingContinuation { continuation in
            var subscription: AnyCancellable?
            subscription = Sign.instance.sessionResponsePublisher
                .filter { $0.id == id }
                .sink { response in
                    subscription?.cancel()
                    switch response.result {
                    case .response(let value):
                        continuation.resume(returning: (try? value.get(String.self)) ?? "")
                    case .error(let error):
                        continuation.resume(throwing: WalletError.rejected(error.message))
                    }
                }
            subscription?.store(in: &cancellables)
        }
    }
}
```

Before you sign anything, **enforce your own spend limit**. The amount and
network came off the wire; check them against what you expect rather than
trusting the response:

```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 ceiling: $5
    else { throw WalletError.rejected("challenge outside expected bounds") }
}
```

### The retry loop

After the transfer, the identical request is what claims the fax. There is no
"submit proof" step: the retry *is* the claim, and the server answers 402 until
Stripe reports the PaymentIntent as `processing` or `succeeded`
(`lib/payments/deposit.ts:82-83`, `lib/payments/gate.ts:299-324`).

```swift
extension SendFax {
    /// Full Flow B. Returns the 202 receipt.
    static func sendWithWallet(
        pdf: Data,
        to: String,
        filename: String = "document.pdf",
        onPhase: @MainActor (String) -> Void = { _ in }
    ) async throws -> Receipt {
        // Build ONCE and reuse: the retry must be byte-identical.
        let body = MultipartBody(pdf: pdf, filename: filename, to: to)

        // 1. Unpaid submit. A 422 for an unsupported destination lands here,
        //    before any deposit address is minted and before any money moves.
        await onPhase("Pricing...")
        guard case .paymentRequired(let challenge) = try await submit(body) else {
            throw SendFaxError.api(status: 202, problem: nil)  // unreachable: paid before asking
        }
        guard let quote = challenge.deposit else {
            // No deposit headers: the server is not in deposit mode. An
            // MPP-native wallet would pay `challenge.mpp` here instead.
            throw SendFaxError.paymentRequired(challenge)
        }

        // 2. Pay the EXACT amount to the per-request address.
        try assertAcceptable(quote, expectedPages: 1)   // pass your real page count
        guard let account = Wallet.account else { throw WalletError.notConnected }
        await onPhase("Confirm \(quote.amountDisplay) USDC in your wallet...")
        _ = try await sendDepositTransfer(quote, from: account)

        // 3. Retry the identical request until 202. Same address for ~15 min
        //    (lib/payments/deposit.ts:50), so this is safe to repeat.
        await onPhase("Waiting for settlement...")
        let deadline = ContinuousClock.now + .seconds(14 * 60)   // inside the TTL
        var delay = Duration.seconds(3)

        while ContinuousClock.now < deadline {
            try Task.checkCancellation()
            try await Task.sleep(for: delay)

            switch try await submit(body) {
            case .accepted(let receipt):
                await onPhase("Accepted")
                return receipt
            case .paymentRequired:
                // Not seen yet. Gentle backoff, capped so we keep polling
                // frequently enough to land well inside the 15-minute window.
                delay = min(delay * 3 / 2, .seconds(15))
            }
        }
        throw SendFaxError.api(status: 402, problem: nil)   // settlement not detected
    }
}
```

Then hand the receipt to the shared poll loop:

```swift
let receipt = try await SendFax.sendWithWallet(pdf: pdf, to: to)
let fax = try await SendFax.waitForDelivery(id: receipt.id) { print($0.status) }
```

---

## Edge cases

**A `422` arrives before any payment.** The route validates `to` and rejects
unsupported countries at `web/app/api/v1/faxes/route.ts:59-64`; the payment gate
does not run until line 81. So an unsupported destination costs nothing and
mints no deposit address. Show
`"SendFax currently delivers to the United States, Canada, Mexico, and Saint
Pierre & Miquelon."` -- that exact sentence is the server's `detail`
(`lib/domain/fax.ts:84-85`). Same ordering for the 20 MB limit (413) and an
unreadable or encrypted PDF (422 "Invalid PDF").

**The amount must be exact.** Stripe matches the deposit by amount. Send
`quote.amountBaseUnits` -- not a rounded display value, not "at least" the price.
An over- or under-payment cannot be auto-matched and needs support to resolve
(`lib/payments/deposit.ts:16-18, 78-80`).

**The deposit address is per request, with a ~15-minute reuse window.** Identical
retries (same PDF bytes, same `to`, same price) reuse the same PaymentIntent for
`DEPOSIT_TTL_MS` = 15 minutes (`lib/payments/deposit.ts:50, 233-252`). Two
consequences: retrying costs nothing and is not a double-charge; and if you pay,
then go quiet for more than 15 minutes, your next attempt mints a **new** address
and your earlier transfer is stranded against a PaymentIntent nothing is
claiming. Keep the retry loop alive from the moment you sign. If your app might
be quit mid-flight, persist `quote.paymentIntent` and the fax parameters so you
can resume or hand the id to support.

**Never send to `challenge.mpp.recipient` in deposit mode.** That address is the
service's static `X402_PAY_TO_ADDRESS` (`lib/runtime/index.ts:185`). The deposit
address is the one in `x-payment-deposit-address`, freshly minted per request.

**Retrying after 202 is idempotent.** The fax row stores the PaymentIntent id,
so a duplicate retry returns the original receipt rather than sending a second
fax (`web/app/api/v1/faxes/route.ts:93-111`). You do not need to guard against a
racing retry.

**`processing` is the settlement bar, not `succeeded`.** The gate accepts a
PaymentIntent as paid once Stripe reports `processing` -- the transfer has been
detected, not yet captured (`lib/payments/deposit.ts:78-83`). Your 202 can arrive
before the on-chain transaction has many confirmations. That is intentional.

**Multi-page pricing.** Price is `pages x 50000` base units on the agent rail
(`lib/domain/pricing.ts:19`) and `max(50, ceil(pages x 5))` cents on Stripe
(`lib/domain/pricing.ts:23`). A one-page fax is $0.05 by wallet and $0.50 by
card -- the floor is Stripe's minimum charge, not our margin. Say that in the UI
if you offer both.

**App Sandbox.** Outgoing network connections need
`com.apple.security.network.client`. Reading a user-picked PDF through
`NSOpenPanel` works under the sandbox; if you resolve the file later, wrap it in
a security-scoped bookmark.

**Timeouts.** `URLSession`'s default 60-second request timeout is fine for every
call here; the 20 MB upload is the only one that can approach it on a slow link.
Set `configuration.timeoutIntervalForRequest` higher if you support large PDFs.

---

## Cross-references

- [README.md](/guides/index.md) -- the chooser and the decision matrix
- [ios.md](/guides/ios.md) -- the same two flows in iOS idioms
- [curl.md](/guides/curl.md) -- a 20-line bash reference implementation of Flow B
- [`sdk/README.md`](https://github.com/DanielSinclair/sendfax) -- the TypeScript SDK this guide mirrors
- <https://sendfax.sh/openapi.json> | <https://sendfax.sh/docs>
