SendFax.sh on iOS

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.


The two flows

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


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:

SFSafariViewControllerASWebAuthenticationSessionUIApplication.open
Stays in your appyesyes (system sheet)no
Apple Pay / Link autofillyesyes (non-ephemeral)yes
Completion callbacknone -- pollcallbackURLScheme never fires for usnone
Detects dismissalvia delegatevia error resultno
Verdictdefaultworks, but its completion handler is a lie herefine 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.


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