SendFax.sh in React Native

SendFax.sh in React Native

React Native is the one platform in this set where the official SDK runs, so this guide uses it. sendfax is dependency-free and built on web-standard fetch, FormData and Blob -- which mostly works on Hermes, with three real gaps this guide names and fixes rather than papering over.

sh
npm install sendfax

The two flows

text
FLOW A -- no wallet
  DocumentPicker --> blob
        |
        +-> client.send({ to, document })          [no PaymentHandler]
        |     +-> throws PaymentRequiredError      <-- this is the fork
        |
        +-> POST /api/v1/uploads   (plain fetch; the SDK has no upload method)
        |     +-> { id, pageCount, amountCents }
        +-> POST /api/v1/checkout  { id }  --> { url }
        +-> WebBrowser.openBrowserAsync(url)       [user pays]
        +-> client.waitForDelivery(id)             --> delivered

FLOW B -- wallet
  DocumentPicker --> blob
        |
        +-> client.send({ to, document })          [with a PaymentHandler]
              +- 402 --> handler:
              |            (i)  MPP wallet signs challenge  -> PAYMENT header
              |            (ii) WalletConnect sends USDC    -> no credential needed
              +- retry --> 202 { id, pageCount, amountUsdMicros, statusUrl }
                            (Flow B(ii) usually needs an OUTER retry -- see below)
        +-> client.waitForDelivery(id)             --> delivered

Which payment path am I in?

Can this app move USDC on Base right now? A live WalletConnect session, an embedded signer, an in-app wallet -- Flow B. Nothing connected -- Flow A. In SDK terms the choice is exactly one constructor option: pass payment and send() pays inline; omit it and send() throws PaymentRequiredError, which is your signal to run the Stripe fallback (sdk/src/client.ts:113-128).


Runtime support: what RN gives you, and what it doesn't

Everything the SDK touches, checked against sdk/src:

APIUsed atHermes / RN
fetchsdk/src/client.ts:111provided
FormDatasdk/src/client.ts:104-108provided, with a filename caveat
Blobsdk/src/client.ts:189-192provided, cannot be built from bytes
AbortSignal / setTimeoutsdk/src/client.ts:218-242provided
atobsdk/src/challenge.ts:216global since RN 0.74; polyfill below that
TextDecodersdk/src/challenge.ts:218not provided -- polyfill required
crypto--not used by the SDK at all

The SDK needs no crypto polyfill. Wallet libraries do (see Flow B).

Gap 1 -- TextDecoder is missing

parseChallenge decodes the base64url request parameter of the MPP challenge through atob then new TextDecoder().decode(...) (sdk/src/challenge.ts:211-224). Hermes ships no TextDecoder. Without it, decodeJson catches the ReferenceError and returns null, so you silently get a challenge with amountBaseUnits: "0" and every field null (sdk/src/challenge.ts:108-124) -- a failure that looks like a server bug and is not one.

sh
npx expo install @bacons/text-decoder   # or: npm install fast-text-encoding
ts
// index.js / App entry -- BEFORE any sendfax import.
import "@bacons/text-decoder/install"

Verify it once at startup rather than discovering it in a payment path:

ts
if (typeof TextDecoder === "undefined") {
  throw new Error("TextDecoder polyfill missing: sendfax cannot parse the 402 challenge")
}

Gap 2 -- atob below RN 0.74

atob/btoa became Hermes globals in React Native 0.74. On older versions:

sh
npm install base-64
ts
import { decode as atob, encode as btoa } from "base-64"
if (typeof global.atob === "undefined") { global.atob = atob; global.btoa = btoa }

Gap 3 -- Blob cannot be constructed from bytes

The SDK converts your document to a Blob with new Blob([bytes], { type: "application/pdf" }) when you hand it a Uint8Array or ArrayBuffer (sdk/src/client.ts:189-192). React Native's Blob polyfill rejects that: it throws "Creating blobs from 'ArrayBuffer' and 'ArrayBufferView' is not supported".

An existing Blob passes through untouched (same lines), so the fix is to hand the SDK a Blob you obtained natively -- RN gives you one from a file URI:

ts
import * as DocumentPicker from "expo-document-picker"

async function pickPdf(): Promise<Blob> {
  const result = await DocumentPicker.getDocumentAsync({
    type: "application/pdf",
    copyToCacheDirectory: true
  })
  if (result.canceled) throw new Error("cancelled")
  const asset = result.assets[0]

  // RN can turn a file:// URI into a real Blob. Do NOT read it to a Uint8Array
  // and let the SDK wrap it -- that path throws on Hermes.
  const blob = await (await fetch(asset.uri)).blob()

  // See Gap 4: RN's FormData reads the filename off the value, not from the
  // SDK's `filename` argument.
  ;(blob as any).name = asset.name ?? "document.pdf"
  return blob
}

Check the size here too -- over 20 MB the API answers 413 (lib/domain/pricing.ts:42).

Gap 4 -- FormData filename

The SDK appends the document as form.append("document", blob, filename) (sdk/src/client.ts:106). React Native's FormData implementation ignores the third argument and instead reads .name and .type off the appended value (react-native/Libraries/Network/FormData.js). The API requires the document part to arrive as a file part -- it checks document instanceof File and answers 400 "Missing document" otherwise (web/app/api/v1/faxes/route.ts:56-58), and a multipart part with no filename parameter parses as a plain string field.

Setting blob.name before you pass it (as in pickPdf above) is the one-line fix. Verify it against the RN version you ship: console.log the response of a real send() against FAX_MODE=mock and confirm you get 402 (the gate) rather than 400 (the parser).

If you would rather not depend on that detail, skip client.send() and do the multipart yourself with RN's native part shape, keeping the SDK for everything else -- that path is spelled out in Flow A and works identically for Flow B:

ts
const form = new FormData()
form.append("document", { uri, name: "document.pdf", type: "application/pdf" } as any)
form.append("to", to)

Flow A -- no wallet: SDK send, catch, fall back to Stripe

send() without a PaymentHandler throws PaymentRequiredError on the 402 (sdk/src/client.ts:115). That is not an error condition -- it is the branch.

The SDK has no upload() or checkout(): it covers the agent rail only (sdk/src/index.ts). The human rail is two plain fetch calls.

ts
import {
  SendFax,
  PaymentRequiredError,
  InvalidDestinationError,
  InvalidDocumentError,
  FaxFailedError,
  type FaxPublic
} from "sendfax"
import * as WebBrowser from "expo-web-browser"
import { Linking } from "react-native"

const BASE_URL = "https://sendfax.sh"
const client = new SendFax()          // no payment handler -> Flow A

/** POST /api/v1/uploads -- validates, page-counts, prices. Charges nothing. */
async function upload(uri: string, to: string) {
  const form = new FormData()
  form.append("document", { uri, name: "document.pdf", type: "application/pdf" } as any)
  form.append("to", to)

  const res = await fetch(`${BASE_URL}/api/v1/uploads`, { method: "POST", body: form })
  if (!res.ok) throw await problemError(res)
  // { id, pageCount, amountCents } -- amountCents is max(50, ceil(pages * 5)).
  // lib/domain/pricing.ts:23
  return (await res.json()) as { id: string; pageCount: number; amountCents: number }
}

/** POST /api/v1/checkout -- hosted Stripe Checkout URL for a prior upload. */
async function checkoutUrl(id: string) {
  const res = await fetch(`${BASE_URL}/api/v1/checkout`, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ id })
  })
  if (!res.ok) throw await problemError(res)
  return ((await res.json()) as { url: string }).url
}

async function problemError(res: Response) {
  const problem = await res.json().catch(() => null)
  return new Error(problem?.detail ?? problem?.title ?? `HTTP ${res.status}`)
}

The whole flow, with the fork made explicit:

ts
export async function sendFax(
  uri: string,
  to: string,
  onStatus: (fax: FaxPublic) => void
): Promise<FaxPublic> {
  const document = await blobFromUri(uri)          // see Gap 3

  let id: string
  try {
    // Optimistic: if a handler is ever configured, this settles inline.
    const receipt = await client.send({ to, document })
    id = receipt.id
  } catch (err) {
    if (!(err instanceof PaymentRequiredError)) throw err

    // --- the fork: no wallet, so pay with Stripe --------------------------
    const up = await upload(uri, to)
    const url = await checkoutUrl(up.id)

    // In-app browser keeps the user in your app; Linking hands off to Safari/
    // Chrome. Either is fine -- neither tells you when payment lands, because
    // Checkout's success_url is https://sendfax.sh/f/{id}?paid=1
    // (lib/payments/stripe.ts:139), not a scheme of yours. You poll.
    await WebBrowser.openBrowserAsync(url)         // or Linking.openURL(url)
    id = up.id
  }

  // Both branches converge here. GET /api/v1/faxes/{id} is free and unauthed.
  return client.waitForDelivery(id, {
    intervalMs: 2000,
    timeoutMs: 300_000,
    onStatus
  })
}

waitForDelivery resolves on delivered and rejects with FaxFailedError on failed/expired, carrying failureReason (sdk/src/client.ts:161-185).

Note on the optimistic send() first. It costs one round trip and gives you the server's validation for free -- an unsupported destination throws InvalidDestinationError before you ever build a Checkout session. If you would rather skip it, call upload() directly; it runs the same validation (web/app/api/v1/uploads/route.ts:49-57).

Errors worth handling by name

ts
try {
  await sendFax(uri, to, setStatus)
} catch (err) {
  if (err instanceof InvalidDestinationError) {
    // 422. Either not E.164, or outside US/CA/MX/PM. err.problem.detail is the
    // exact sentence to show. lib/domain/fax.ts:84-85
    setError(err.problem?.detail ?? err.message)
  } else if (err instanceof InvalidDocumentError) {
    // 400 / 413 / 422: not a PDF, encrypted, zero pages, or over 20 MB.
    setError(err.message)
  } else if (err instanceof FaxFailedError) {
    setError(`Delivery failed: ${err.failureReason ?? "unknown"}`)
  } else {
    setError(String(err))
  }
}

Foreground-only polling

waitForDelivery is a plain interval loop; iOS suspends it when your app backgrounds and Android may too under Doze. Cancel on background, restart on foreground, and offer the status page as the escape hatch:

ts
import { AppState } from "react-native"

const controller = new AbortController()
const sub = AppState.addEventListener("change", (s) => {
  if (s !== "active") controller.abort()
})

try {
  await client.waitForDelivery(id, { signal: controller.signal, onStatus })
} finally {
  sub.remove()
}

// Anywhere in the UI:
<Button title="Follow in browser"
        onPress={() => Linking.openURL(`https://sendfax.sh/f/${id}`)} />

Nothing is lost by stopping. Status lives on the server and the id is durable; one client.get(id) catches you up.


Flow B -- wallet: implement a PaymentHandler

A PaymentHandler is (challenge, attempt) => Promise<{ headerName, headerValue }> (sdk/src/payment.ts:45-48). The SDK calls it once on the 402 and retries the request with that header (sdk/src/client.ts:117-122).

There are two ways to satisfy it, and they differ in more than the signer.

(i) MPP-capable wallet -- sign the challenge

This is the contract the handler was designed for. Your wallet SDK consumes the WWW-Authenticate: Payment header and produces a credential; you return it.

ts
import { SendFax, type PaymentHandler } from "sendfax"

const payment: PaymentHandler = async (challenge) => {
  // challenge.raw.wwwAuthenticate is the verbatim header; challenge.amountBaseUnits
  // ("50000" = $0.05), .tokenAddress, .chainId, .recipient and .decimals are the
  // parsed fields. sdk/src/challenge.ts:36-75
  const credential = await myMppWallet.authorize(challenge.raw.wwwAuthenticate!)
  return { headerName: "PAYMENT", headerValue: credential }
  // x402-native signer instead? { headerName: "X-PAYMENT", headerValue: signed }
}

const client = new SendFax({ payment })
const receipt = await client.send({ to, document })   // 402 -> pay -> 202

One send() call, one retry, done -- because a signed credential is verified synchronously by the gate.

Caveat, stated plainly: whether that credential settles depends on server configuration. When Stripe deposit mode is on -- the wired path today (lib/payments/gate.ts:25-34, lib/runtime/index.ts:107) -- the gate decides paid by polling a Stripe PaymentIntent and never inspects your credential (lib/payments/gate.ts:299-324). A signed MPP/x402 authorization is accepted as wire-format discovery but settles nothing. Path (ii) is what actually moves money today; keep (i) as the forward-compatible branch and be prepared for it to 402.

(ii) WalletConnect / wagmi -- send the deposit transfer

Deposit mode wants a plain ERC-20 transfer of the exact amount to a per-request address, and no credential at all: the retry itself is the claim (lib/payments/deposit.ts:1-34). Mapping that onto the handler contract takes two adaptations, both of which are workarounds for real gaps rather than idiomatic SDK use.

Adaptation 1 -- get the deposit address. parseChallenge reads only WWW-Authenticate and PAYMENT-REQUIRED (sdk/src/client.ts:207-215, sdk/src/challenge.ts:92-125). It does not read the x-payment-deposit-* headers, so challenge.recipient is the service's static receiving address (X402_PAY_TO_ADDRESS, lib/runtime/index.ts:185), not the per-request deposit address. Sending to challenge.recipient settles nothing and loses the money.

The SDK's fetch injection point is the fix: wrap fetch, capture the deposit headers off any 402, and read them in the handler.

ts
import { SendFax, type PaymentHandler } from "sendfax"

export type DepositQuote = {
  network: string          // "base"
  address: string          // per-request -- send HERE
  token: string            // USDC ERC-20 contract
  amountDisplay: string    // "0.050000" -- for the UI
  amountBaseUnits: bigint  // 50000n -- for the transfer
  paymentIntent: string    // "pi_..." -- persist this
}

/** Captures the deposit quote the SDK's parser drops. gate.ts:219-234 */
function makeDepositCapture() {
  let latest: DepositQuote | null = null

  const capturingFetch: typeof fetch = async (input, init) => {
    const res = await fetch(input, init)
    if (res.status === 402) {
      const h = res.headers
      const address = h.get("x-payment-deposit-address")
      const micros = h.get("x-payment-deposit-amount-micros")
      if (address && micros) {
        latest = {
          network: h.get("x-payment-deposit-network") ?? "base",
          address,
          token: h.get("x-payment-deposit-token")
            ?? "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
          amountDisplay: h.get("x-payment-deposit-amount") ?? "",
          // Integer base units. Never parse the decimal display string through
          // a float: deposit mode matches EXACTLY. lib/payments/deposit.ts:16-18
          amountBaseUnits: BigInt(micros),
          paymentIntent: h.get("x-payment-deposit-payment-intent") ?? ""
        }
      }
    }
    return res
  }

  return { capturingFetch, quote: () => latest }
}

Adaptation 2 -- return a header you do not have. The handler's return type is required (sdk/src/payment.ts:19-24); deposit mode has no credential to put in it. Return an informational header. The gate ignores request headers entirely on the deposit path -- it hardcodes rail: "x402" and decides from the PaymentIntent (lib/payments/gate.ts:299-324), so any header name is inert.

Pick a name outside the payment namespace. x-payment and payment-signature are the two the gate sniffs for rail labelling (lib/payments/gate.ts:88-90, 196-200); harmless in deposit mode, but if the server is ever reconfigured with a facilitator they would be read as a real credential and fail verification. A custom x-sendfax-deposit-tx carries your transaction hash, is useful in support tickets, and can never be mistaken for a payment credential.

ts
import { createWalletClient, custom, encodeFunctionData, erc20Abi } from "viem"
import { base } from "viem/chains"

function makeDepositHandler(
  quoteOf: () => DepositQuote | null,
  wallet: ReturnType<typeof createWalletClient>,
  account: `0x${string}`,
  expectedPages: number
): PaymentHandler {
  let paidFor: string | null = null      // PaymentIntent we already funded

  return async () => {
    const quote = quoteOf()
    if (!quote) throw new Error("402 carried no deposit headers; server not in deposit mode")

    // Pay at most once per PaymentIntent, however many times we are called.
    if (paidFor !== quote.paymentIntent) {
      // Enforce your own limits: the amount arrived over the wire.
      const expected = BigInt(expectedPages) * 50_000n     // lib/domain/pricing.ts:15
      if (quote.network !== "base" || quote.amountBaseUnits !== expected) {
        throw new Error(`unexpected charge: ${quote.amountDisplay} on ${quote.network}`)
      }

      const hash = await wallet.sendTransaction({
        account,
        chain: base,
        to: quote.token as `0x${string}`,            // the USDC contract
        data: encodeFunctionData({
          abi: erc20Abi,
          functionName: "transfer",
          args: [quote.address as `0x${string}`, quote.amountBaseUnits]
        })
      })
      paidFor = quote.paymentIntent
      lastTxHash = hash
    }

    // No credential exists in deposit mode: the retry itself settles. This
    // header is informational and the gate never reads it.
    return { headerName: "x-sendfax-deposit-tx", headerValue: lastTxHash ?? "pending" }
  }
}

let lastTxHash: string | null = null

Adaptation 3 -- the SDK retries once; settlement takes longer. send() pays and retries exactly one time, then throws PaymentRequiredError if that retry is still a 402 (sdk/src/client.ts:117-127). A USDC transfer on Base needs a few seconds to land and for Stripe to report the PaymentIntent as processing (lib/payments/deposit.ts:78-83), so the single retry will usually still 402. That is expected, not a failure.

The outer loop is safe because the server correlates retries by hashing to + amount + document bytes and reuses the same PaymentIntent for 15 minutes (lib/payments/deposit.ts:50, 94-108, 233-252). Identical send() calls are not a double charge, and the handler's paidFor guard means the wallet is asked once.

ts
export async function sendWithWallet(
  document: Blob,
  to: string,
  wallet: ReturnType<typeof createWalletClient>,
  account: `0x${string}`,
  expectedPages: number
) {
  const { capturingFetch, quote } = makeDepositCapture()
  const client = new SendFax({
    fetch: capturingFetch,
    payment: makeDepositHandler(quote, wallet, account, expectedPages)
  })

  // Stay inside the 15-minute deposit reuse window.
  const deadline = Date.now() + 14 * 60_000
  let delay = 3000

  while (Date.now() < deadline) {
    try {
      return await client.send({ to, document })     // 202 -> SendFaxResult
    } catch (err) {
      if (!(err instanceof PaymentRequiredError)) throw err   // 422 etc: give up
      await new Promise((r) => setTimeout(r, delay))
      delay = Math.min(Math.round(delay * 1.5), 15_000)
    }
  }
  throw new Error("deposit not detected within the 15-minute window")
}

Wallet wiring

Reown AppKit for React Native (or wagmi + WalletConnect) provides the EIP-1193 provider that viem wraps:

sh
npx expo install @reown/appkit-wagmi-react-native wagmi viem \
  react-native-get-random-values @react-native-async-storage/async-storage \
  react-native-svg react-native-modal
ts
// Entry file, before anything else. Wallet libs need real randomness; the
// sendfax SDK itself uses no crypto at all.
import "react-native-get-random-values"
import "@bacons/text-decoder/install"
ts
import { createWalletClient, custom } from "viem"
import { base } from "viem/chains"

const wallet = createWalletClient({ chain: base, transport: custom(walletProvider) })
const [account] = await wallet.getAddresses()

Signing happens in the user's wallet app, which backgrounds yours. Persist quote.paymentIntent and the fax parameters before you deep-link out, and resume the retry loop when your app returns -- see the 15-minute edge case below.


Local development

Against a dev server in FAX_MODE=mock, mockPaymentHandler() sends the x-mock-payment bypass header and every send settles instantly with no wallet (sdk/src/payment.ts:66-68, honored at lib/payments/gate.ts:284-293).

ts
import { SendFax, mockPaymentHandler } from "sendfax"

const client = new SendFax({
  // Android emulator: 10.0.2.2 is the host machine.
  baseUrl: __DEV__ ? "http://10.0.2.2:3001" : "https://sendfax.sh",
  payment: __DEV__ ? mockPaymentHandler() : realHandler
})

Plain HTTP to a dev server needs NSAllowsLocalNetworking on iOS and usesCleartextTraffic (or a network security config) on Android. Mock mode also drives the status lifecycle on read -- GET advances the fax one step every 3 seconds (web/app/api/v1/faxes/[id]/route.ts:24-32) -- so waitForDelivery reaches delivered in about 15 seconds. Never ship mockPaymentHandler: against production it pays nothing and every send 402s.


Edge cases

422 arrives before any payment. to is validated and unsupported countries rejected at web/app/api/v1/faxes/route.ts:59-64, before the gate runs at line 81 -- so no deposit address is minted and no money moves. The SDK surfaces this as InvalidDestinationError (sdk/src/errors.ts:117-120, keyed on the problem title). Show err.problem?.detail: "SendFax currently delivers to the United States, Canada, Mexico, and Saint Pierre & Miquelon."

A +1 number can still fail at the carrier. +1 is the whole NANP, so a +1876 Jamaican number passes validation and fails at Telnyx (lib/domain/fax.ts:87-97). On a Stripe-settled payment -- including the deposit rail -- that is auto-refunded to the sending wallet (lib/payments/refund.ts:1-30).

The amount must be exact. Use amountBaseUnits from x-payment-deposit-amount-micros. Deposit mode matches to the base unit; an over- or under-payment cannot be auto-matched (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). Past that, the next attempt mints a new address and your earlier transfer is stranded against a PaymentIntent nothing is claiming. This is the likeliest mobile failure, because the wallet round trip already backgrounds your app. Persist paymentIntent plus the fax parameters before deep-linking, resume the retry loop on foreground, and if the window has passed, surface the PaymentIntent id rather than silently charging again.

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 settlement bar (lib/payments/deposit.ts:78-83). Your 202 can arrive before the transfer is deeply confirmed. Intentional.

Pricing differs by rail. $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). If your UI offers both, show both numbers -- a one-page fax is $0.05 or $0.50 depending on the button.

Hermes and BigInt. Supported since RN 0.70. viem needs it; so does the BigInt(micros) above.


SDK friction, summarized honestly

Four places where the SDK's shape and deposit-mode settlement do not line up. None are blockers; all need a workaround you should understand rather than copy blindly.

FrictionWhereWorkaround here
parseChallenge ignores x-payment-deposit-*; challenge.recipient is the static address, not the deposit addresssdk/src/challenge.ts:92-125 vs lib/payments/gate.ts:219-234wrap fetch and capture the headers
PaymentHandler must return a header; deposit mode has no credentialsdk/src/payment.ts:19-24 vs lib/payments/deposit.ts:20-29return x-sendfax-deposit-tx; the gate ignores it
send() retries once; settlement takes seconds to minutessdk/src/client.ts:117-127outer retry loop, safe via the 15-min payload-hash reuse
No upload() / checkout() for the human fallbacksdk/src/index.tstwo plain fetch calls

Plus three runtime gaps that are RN's, not the SDK's: TextDecoder, Blob from bytes, and the FormData filename -- all covered above.


Cross-references