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

- SDK source: [`sdk/src/`](https://github.com/DanielSinclair/sendfax) | reference: [`sdk/README.md`](https://github.com/DanielSinclair/sendfax)
- Contract: <https://sendfax.sh/openapi.json> | docs: <https://sendfax.sh/docs>
- Shared payment framework: [README.md](/guides/index.md)

---

## The two flows

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

| API | Used at | Hermes / RN |
|---|---|---|
| `fetch` | `sdk/src/client.ts:111` | provided |
| `FormData` | `sdk/src/client.ts:104-108` | provided, **with a filename caveat** |
| `Blob` | `sdk/src/client.ts:189-192` | provided, **cannot be built from bytes** |
| `AbortSignal` / `setTimeout` | `sdk/src/client.ts:218-242` | provided |
| `atob` | `sdk/src/challenge.ts:216` | global since RN 0.74; polyfill below that |
| `TextDecoder` | `sdk/src/challenge.ts:218` | **not 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](#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)
```

---

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

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

---

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

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

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

### (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.

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

### (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.

| Friction | Where | Workaround here |
|---|---|---|
| `parseChallenge` ignores `x-payment-deposit-*`; `challenge.recipient` is the static address, not the deposit address | `sdk/src/challenge.ts:92-125` vs `lib/payments/gate.ts:219-234` | wrap `fetch` and capture the headers |
| `PaymentHandler` must return a header; deposit mode has no credential | `sdk/src/payment.ts:19-24` vs `lib/payments/deposit.ts:20-29` | return `x-sendfax-deposit-tx`; the gate ignores it |
| `send()` retries once; settlement takes seconds to minutes | `sdk/src/client.ts:117-127` | outer retry loop, safe via the 15-min payload-hash reuse |
| No `upload()` / `checkout()` for the human fallback | `sdk/src/index.ts` | two 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

- [README.md](/guides/index.md) -- the chooser and the decision matrix
- [ios.md](/guides/ios.md) / [macos.md](/guides/macos.md) -- the same flows in native Swift
- [typescript.md](/guides/typescript.md) -- the same `PaymentHandler` pattern with a local key
- [`sdk/README.md`](https://github.com/DanielSinclair/sendfax) -- full SDK reference
- <https://sendfax.sh/openapi.json> | <https://sendfax.sh/docs>
