SendFax.sh on native macOS

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.


The two flows

text
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.


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.openASWebAuthenticationSession
Presentationuser's default browser, separate appin-app sheet, ephemeral session
Session cookiesshares Safari/Chrome login, so Stripe Link autofillsisolated unless you pass prefersEphemeralWebBrowserSession = false
Completion signalnone -- you pollalso none for us -- callbackURLScheme never fires
Cancel detectionnoneyes, if the user dismisses the sheet
Recommendationdefaultuse 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.


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