# SendFax.sh in a React / Next.js web app

Browser-side integration. The `sendfax` npm SDK runs unmodified in browsers, so
most of this guide is about the two things a browser adds that no other platform
has: **the same-origin problem** and **the fact that everything you ship is
public**.

Neither is a blocker. Both need a deliberate answer, and the answer to the first
one is a Next.js route handler.

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

```
                        browser (public code)
                              |
                     your Next.js origin
                              |
     +------------------------+------------------------+
     |  app/api/fax/*  route handlers (server)          |
     |  proxy to https://sendfax.sh -- same-origin for  |
     |  the browser, and the ONLY place a wallet key    |
     |  or a rate limit could live                      |
     +------------------------+------------------------+
                              |
                        sendfax.sh API

FLOW A -- no wallet (Stripe hosted Checkout)
  <input type=file> / drop zone
        +-> POST /api/fax/upload    --proxy--> POST /api/v1/uploads
        |     +-> { id, pageCount, amountCents }
        +-> POST /api/fax/checkout  --proxy--> POST /api/v1/checkout
        |     +-> { url }
        +-> window.location.href = url          [full-page redirect to Stripe]
        +-> user returns to https://sendfax.sh/f/{id}?paid=1  (NOT your site)
        +-> meanwhile/afterwards: poll GET /api/fax/status/{id}

FLOW B -- wallet (pay the 402 in the browser)
  <input type=file>
        +-> POST /api/fax/send      --proxy--> POST /api/v1/faxes
        |     +-> 402 + x-payment-deposit-*  (proxy must FORWARD these headers)
        +-> wagmi writeContract: exact USDC transfer on Base
        +-> POST /api/fax/send      (identical body) ... retry ... -> 202
        +-> poll GET /api/fax/status/{id} with SWR / React Query
```

## Which payment path am I in?

**Can this browser move USDC on Base right now?** A connected wagmi account
means Flow B. No wallet, a wrong chain, or a user who would rather pay by card
means Flow A. Ship both: render the wallet button when `isConnected && chainId
=== base.id`, and the "Pay with card" button always.

In SDK terms it is one constructor option -- pass a `PaymentHandler` and
`send()` pays inline, omit it and `send()` throws `PaymentRequiredError`, which
is your cue to run the Stripe flow (`sdk/src/client.ts:113-128`).

---

## First: there are no secret keys

SendFax has no accounts and no API keys. Nothing about calling this API needs a
credential, so there is no `SENDFAX_API_KEY` to leak. That is worth saying out
loud because it removes the usual reason for a backend.

What *must* never reach the browser:

- **A wallet private key.** If your app pays for faxes from a company wallet
  rather than the user's, that key belongs in a server route handler and nowhere
  else. `NEXT_PUBLIC_` anything is shipped to every visitor.
- **Any spend authority you are not willing to give every visitor.** A server
  route that pays from your wallet is an open faucet unless you put auth and a
  rate limit in front of it. The API charges $0.05/page; a script can spend real
  money through an unguarded proxy quickly.

The user's own wallet (Flow B) is the opposite case: the key stays in their
extension, the browser only requests a signature, and your app spends nothing.

---

## The CORS reality

**The SendFax API sends no CORS headers and implements no `OPTIONS` handler.**
Verified across every route: `web/app/api/v1/uploads/route.ts`,
`checkout/route.ts`, `faxes/route.ts`, and `faxes/[id]/route.ts` return plain
`Response.json(...)` / `problem(...)` values with no `Access-Control-*` headers;
the site middleware does not match `/api` paths at all
(`web/middleware.ts:129-131`); and `web/next.config.ts` adds no headers.

So calling `https://sendfax.sh/api/v1/...` directly from browser JavaScript on
your own origin fails, in two different and confusingly different ways:

| Call | What the browser does | What you see |
|---|---|---|
| `POST /api/v1/uploads` or `/faxes` with `FormData` | `multipart/form-data` is a CORS-safelisted content type, so there is **no preflight**. The request is genuinely sent and the server genuinely processes it. The browser then refuses to let you read the response. | A network error in JS -- but the fax may have been uploaded. |
| `POST /api/v1/checkout` with `content-type: application/json` | Not safelisted, so the browser preflights with `OPTIONS`. There is no handler, so the preflight fails. | The request is never sent. |
| `GET /api/v1/faxes/{id}` | Simple request, sent, response unreadable. | A network error, with the request in your Network tab. |

The first row is the dangerous one: a naive direct call can upload a document
and start a payment flow you have no id for.

### The fix: proxy route handlers

Put the API behind your own origin. Four small route handlers, no dependencies:

```ts
// app/api/fax/upload/route.ts -- proxies POST /api/v1/uploads
export const runtime = "edge"          // or "nodejs"; both work

const SENDFAX = "https://sendfax.sh"

export async function POST(request: Request): Promise<Response> {
  // Stream the multipart body straight through. Do not re-parse it: the bytes
  // must reach SendFax unchanged, and re-encoding is a chance to corrupt them.
  const upstream = await fetch(`${SENDFAX}/api/v1/uploads`, {
    method: "POST",
    headers: { "content-type": request.headers.get("content-type") ?? "" },
    body: request.body,
    // @ts-expect-error -- required by undici to stream a request body
    duplex: "half"
  })

  return new Response(upstream.body, {
    status: upstream.status,
    headers: {
      "content-type": upstream.headers.get("content-type") ?? "application/json"
    }
  })
}
```

```ts
// app/api/fax/checkout/route.ts -- proxies POST /api/v1/checkout
export async function POST(request: Request): Promise<Response> {
  const body = await request.text()
  const upstream = await fetch("https://sendfax.sh/api/v1/checkout", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body
  })
  return new Response(await upstream.text(), {
    status: upstream.status,
    headers: { "content-type": upstream.headers.get("content-type") ?? "application/json" }
  })
}
```

```ts
// app/api/fax/status/[id]/route.ts -- proxies GET /api/v1/faxes/{id}
export async function GET(
  _request: Request,
  { params }: { params: Promise<{ id: string }> }
): Promise<Response> {
  const { id } = await params
  const upstream = await fetch(
    `https://sendfax.sh/api/v1/faxes/${encodeURIComponent(id)}`,
    { cache: "no-store" }
  )
  return new Response(await upstream.text(), {
    status: upstream.status,
    headers: {
      "content-type": upstream.headers.get("content-type") ?? "application/json",
      // Status changes every couple of seconds; never let a CDN hold it.
      "cache-control": "no-store"
    }
  })
}
```

The agent-flow proxy needs one extra thing -- **forward the deposit headers**,
or Flow B cannot work:

```ts
// app/api/fax/send/route.ts -- proxies POST /api/v1/faxes
const FORWARD_HEADERS = [
  "www-authenticate",
  "payment-required",
  "payment-receipt",
  "x-payment-deposit-mode",
  "x-payment-deposit-network",
  "x-payment-deposit-address",
  "x-payment-deposit-token",
  "x-payment-deposit-amount",
  "x-payment-deposit-amount-micros",
  "x-payment-deposit-payment-intent",
  "x-payment-deposit-instructions"
] as const

export async function POST(request: Request): Promise<Response> {
  const upstream = await fetch("https://sendfax.sh/api/v1/faxes", {
    method: "POST",
    headers: { "content-type": request.headers.get("content-type") ?? "" },
    body: request.body,
    // @ts-expect-error -- required by undici to stream a request body
    duplex: "half"
  })

  const headers = new Headers({
    "content-type": upstream.headers.get("content-type") ?? "application/json"
  })
  for (const name of FORWARD_HEADERS) {
    const value = upstream.headers.get(name)
    if (value) headers.set(name, value)
  }

  return new Response(upstream.body, { status: upstream.status, headers })
}
```

Two gotchas that will cost you an afternoon each:

- **`duplex: "half"`** is required when passing a `ReadableStream` as a fetch
  body in Node's undici. Without it you get `RequestInit: duplex option is
  required when sending a body`. On the edge runtime it is accepted and ignored.
- **Same-origin responses expose all headers to JS.** The `Access-Control-Expose-Headers`
  dance only applies cross-origin, which is exactly what you have just stopped
  doing -- so once you proxy, `res.headers.get("x-payment-deposit-address")`
  works from the browser.

Do not add `Access-Control-Allow-Origin: *` to your proxy and call it done. That
turns your server into an open relay for anyone's fax traffic and, in Flow B
with a server-side wallet, for anyone's spending.

### If you would rather not proxy

Two alternatives, both worse for a normal app:

- **Do everything server-side.** Server actions or route handlers that take the
  file, call SendFax, and return only the fax id. Fewer moving parts, but the
  20 MB PDF makes two hops and you lose client-side progress. Reasonable for a
  form-post app.
- **Send the user to `https://sendfax.sh/human`.** No integration at all. If
  faxing is a side feature, this is a legitimate answer and takes one `<a>`.

---

## Picking the PDF

A plain file input and a drop zone, no library:

```tsx
"use client"
import { useCallback, useState } from "react"

const MAX_BYTES = 20 * 1024 * 1024     // lib/domain/pricing.ts:42

export function PdfPicker({ onPick }: { onPick: (file: File) => void }) {
  const [over, setOver] = useState(false)
  const [error, setError] = useState<string | null>(null)

  const accept = useCallback(
    (file: File | undefined) => {
      if (!file) return
      if (file.type !== "application/pdf") return setError("That is not a PDF.")
      if (file.size > MAX_BYTES) return setError("PDFs must be 20 MB or smaller.")
      setError(null)
      onPick(file)
    },
    [onPick]
  )

  return (
    <div
      onDragOver={(e) => { e.preventDefault(); setOver(true) }}
      onDragLeave={() => setOver(false)}
      onDrop={(e) => { e.preventDefault(); setOver(false); accept(e.dataTransfer.files[0]) }}
      data-over={over}
    >
      <label>
        Choose a PDF
        <input
          type="file"
          accept="application/pdf,.pdf"
          onChange={(e) => accept(e.target.files?.[0])}
          hidden
        />
      </label>
      {error && <p role="alert">{error}</p>}
    </div>
  )
}
```

Checking size and type locally is a courtesy; the server checks anyway and
returns `413` for size (`web/app/api/v1/faxes/route.ts:65-67`) and `422 "Invalid
PDF"` for an encrypted or unreadable document (`lib/runtime/index.ts:262-265`).
Encrypted PDFs are the common surprise -- bank statements often are.

Page count, and therefore price, is whatever the server counts. Do not try to
count pages in the browser to show a price; call `/api/fax/upload` and use the
`pageCount` and `amountCents` it returns.

---

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

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

Two proxied calls, then a full-page redirect.

```tsx
"use client"
import { useState } from "react"

type Upload = { id: string; pageCount: number; amountCents: number }

export function CardCheckout({ file, to }: { file: File; to: string }) {
  const [busy, setBusy] = useState(false)
  const [quote, setQuote] = useState<Upload | null>(null)
  const [error, setError] = useState<string | null>(null)

  async function priceIt() {
    const form = new FormData()
    form.append("document", file)          // browsers set the filename from the File
    form.append("to", to)

    const res = await fetch("/api/fax/upload", { method: "POST", body: form })
    if (!res.ok) {
      const problem = await res.json().catch(() => null)
      // 422 "Unsupported destination" arrives here, before any payment.
      throw new Error(problem?.detail ?? `Upload failed (${res.status})`)
    }
    return (await res.json()) as Upload
  }

  async function pay() {
    setBusy(true)
    setError(null)
    try {
      const up = quote ?? (await priceIt())
      setQuote(up)

      const res = await fetch("/api/fax/checkout", {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({ id: up.id })
      })
      if (!res.ok) throw new Error(`Checkout failed (${res.status})`)
      const { url } = (await res.json()) as { url: string }

      // Full-page navigation. NOT router.push -- this is a different origin,
      // and NOT window.open, which popup blockers eat when it is not in the
      // direct call stack of a click.
      window.location.href = url
    } catch (err) {
      setError(err instanceof Error ? err.message : String(err))
      setBusy(false)
    }
  }

  return (
    <div>
      {quote && (
        <p>
          {quote.pageCount} page(s) -- ${(quote.amountCents / 100).toFixed(2)}
        </p>
      )}
      <button onClick={pay} disabled={busy}>
        {busy ? "Redirecting..." : "Pay with card"}
      </button>
      {error && <p role="alert">{error}</p>}
    </div>
  )
}
```

### Where the user comes back

**They do not come back to you.** Checkout's `success_url` is
`https://sendfax.sh/f/{id}?paid=1` (`lib/payments/stripe.ts:139`) -- the SendFax
status page. You cannot configure it from the API.

Plan for that:

- **Persist the fax id before redirecting.** `localStorage.setItem("sendfax:last",
  id)` costs nothing and lets you show the status when they navigate back.
- **Show the status URL before they leave**, so they can bookmark it:
  `https://sendfax.sh/f/{id}` is a live, shareable page.
- **If you want them on your page afterwards**, keep the tab: render your own
  status view immediately and open Checkout in a new tab from the click handler
  (`window.open(url, "_blank")` directly inside `onClick`, not after an `await`,
  or the popup blocker will stop it). Your original tab keeps polling. This is
  the better UX when faxing is one step of a longer flow.

`window.location.href` is the right default anyway: it survives popup blockers,
works on mobile Safari, and matches what users expect from a payment link.

---

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

## Flow B -- wallet: pay the 402 in the browser

### Setup

```sh
npm install wagmi viem @tanstack/react-query
```

```tsx
// app/providers.tsx
"use client"
import { WagmiProvider, createConfig, http } from "wagmi"
import { base } from "wagmi/chains"
import { injected, walletConnect } from "wagmi/connectors"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"

const config = createConfig({
  chains: [base],
  connectors: [
    injected(),
    walletConnect({ projectId: process.env.NEXT_PUBLIC_WC_PROJECT_ID! })
  ],
  transports: { [base.id]: http() },
  ssr: true                 // Next.js App Router: avoids a hydration mismatch
})

const queryClient = new QueryClient()

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <WagmiProvider config={config}>
      <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
    </WagmiProvider>
  )
}
```

A WalletConnect project id is public by design -- `NEXT_PUBLIC_` is correct
here. It identifies your app to the relay; it is not a secret.

### Reading the 402

```ts
export type DepositQuote = {
  network: string
  address: `0x${string}`
  token: `0x${string}`
  amountDisplay: string      // "0.050000" -- for the UI
  amountBaseUnits: bigint    // 50000n -- for the transfer
  paymentIntent: string      // "pi_..." -- persist this
}

const USDC_BASE = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" as const

/** Deposit headers defined at lib/payments/gate.ts:219-234. */
function readDeposit(res: Response): DepositQuote | null {
  const h = res.headers
  const address = h.get("x-payment-deposit-address")
  const micros = h.get("x-payment-deposit-amount-micros")
  if (!address || !micros) return null
  return {
    network: h.get("x-payment-deposit-network") ?? "base",
    address: address as `0x${string}`,
    token: (h.get("x-payment-deposit-token") ?? USDC_BASE) as `0x${string}`,
    amountDisplay: h.get("x-payment-deposit-amount") ?? "",
    // Integer base units. Deposit mode matches EXACTLY, so never round-trip the
    // decimal display string through a float. lib/payments/deposit.ts:16-18
    amountBaseUnits: BigInt(micros),
    paymentIntent: h.get("x-payment-deposit-payment-intent") ?? ""
  }
}
```

### The hook

```tsx
"use client"
import { useState } from "react"
import { useAccount, useChainId, useSwitchChain, useWriteContract } from "wagmi"
import { base } from "wagmi/chains"
import { erc20Abi } from "viem"

type Receipt = { id: string; pageCount: number; amountUsdMicros: number; statusUrl: string }

export function useWalletFax() {
  const { address, isConnected } = useAccount()
  const chainId = useChainId()
  const { switchChainAsync } = useSwitchChain()
  const { writeContractAsync } = useWriteContract()
  const [phase, setPhase] = useState<string>("idle")

  async function send(file: File, to: string): Promise<Receipt> {
    if (!isConnected || !address) throw new Error("Connect a wallet first.")
    if (chainId !== base.id) await switchChainAsync({ chainId: base.id })

    // Buffer once. Every retry must send byte-identical content, and a File
    // read twice from a picker is not guaranteed to be the same object.
    const bytes = new Uint8Array(await file.arrayBuffer())
    const buildBody = () => {
      const form = new FormData()
      form.append("document", new Blob([bytes], { type: "application/pdf" }), file.name)
      form.append("to", to)
      return form
    }

    // 1. Unpaid submit. A 422 for an unsupported destination lands here,
    //    before any address is minted and before any money moves.
    setPhase("pricing")
    let res = await fetch("/api/fax/send", { method: "POST", body: buildBody() })
    if (res.status === 202) return (await res.json()) as Receipt
    if (res.status !== 402) {
      const problem = await res.json().catch(() => null)
      throw new Error(problem?.detail ?? `Request failed (${res.status})`)
    }

    const quote = readDeposit(res)
    if (!quote) throw new Error("402 carried no deposit headers.")

    // 2. Check the charge against expectations BEFORE asking for a signature.
    if (quote.network !== "base") throw new Error(`unexpected network ${quote.network}`)
    if (quote.token.toLowerCase() !== USDC_BASE.toLowerCase())
      throw new Error("unexpected token")
    if (quote.amountBaseUnits > 5_000_000n) throw new Error("charge over my ceiling")

    setPhase(`confirm ${quote.amountDisplay} USDC`)
    localStorage.setItem("sendfax:pendingPI", quote.paymentIntent)

    await writeContractAsync({
      abi: erc20Abi,
      address: quote.token,                 // the USDC contract
      functionName: "transfer",
      args: [quote.address, quote.amountBaseUnits]   // the deposit address
    })

    // 3. Retry the identical request until 202. Same address for ~15 minutes
    //    (lib/payments/deposit.ts:50), so this is safe to repeat and is not a
    //    second charge.
    setPhase("settling")
    const deadline = Date.now() + 14 * 60_000
    let delay = 3000
    while (Date.now() < deadline) {
      await new Promise((r) => setTimeout(r, delay))
      res = await fetch("/api/fax/send", { method: "POST", body: buildBody() })
      if (res.status === 202) {
        setPhase("accepted")
        return (await res.json()) as Receipt
      }
      delay = Math.min(Math.round(delay * 1.5), 15_000)
    }
    throw new Error("Deposit not detected within the 15-minute window.")
  }

  return { send, phase }
}
```

`writeContractAsync` resolves with the transaction hash once the user approves;
it does **not** wait for a receipt. That is what you want -- the retry loop is
already polling for the thing that matters, which is Stripe seeing the transfer.
Adding `useWaitForTransactionReceipt` only makes the user wait longer for the
same outcome.

### Things browsers do that other platforms do not

- **A wallet popup can be dismissed silently.** `writeContractAsync` rejects
  with a user-rejection error; catch it and let them retry without re-uploading.
- **The user can switch chains mid-flow.** `useChainId` is reactive; re-check
  before the transfer, not just at mount.
- **A tab can be backgrounded.** `setTimeout` is throttled in background tabs
  (to roughly once a minute in Chrome). Your retry loop keeps working but slows
  down, which matters against a 15-minute window. Consider warning the user to
  keep the tab open, or persist and resume.
- **Nothing is secret.** The deposit address and amount are visible in DevTools.
  That is fine -- they are per-request and useless to anyone else.

---

## Polling status with SWR or React Query

`GET /api/v1/faxes/{id}` is free and unauthenticated, so this is just polling.
Stop when terminal.

```tsx
"use client"
import useSWR from "swr"

type FaxPublic = {
  id: string
  status: "awaiting_payment" | "paid" | "queued" | "media_processed"
        | "sending" | "delivered" | "failed" | "expired"
  pageCount: number
  toMasked: string
  failureReason: string | null
  createdAt: number
  terminalAt: number | null
}

const TERMINAL = ["delivered", "failed", "expired"]   // sdk/src/types.ts:27

export function useFaxStatus(id: string | null) {
  return useSWR<FaxPublic>(
    id ? `/api/fax/status/${id}` : null,
    (url: string) => fetch(url).then((r) => r.json()),
    {
      // Stop polling once terminal; SWR passes the latest data to the interval fn.
      refreshInterval: (data) => (data && TERMINAL.includes(data.status) ? 0 : 2000),
      revalidateOnFocus: true
    }
  )
}
```

React Query, same idea:

```tsx
import { useQuery } from "@tanstack/react-query"

export function useFaxStatus(id: string | null) {
  return useQuery<FaxPublic>({
    queryKey: ["fax", id],
    queryFn: () => fetch(`/api/fax/status/${id}`).then((r) => r.json()),
    enabled: Boolean(id),
    refetchInterval: (query) =>
      query.state.data && TERMINAL.includes(query.state.data.status) ? false : 2000
  })
}
```

Render it:

```tsx
function FaxStatus({ id }: { id: string }) {
  const { data } = useFaxStatus(id)
  if (!data) return <p>Starting...</p>
  return (
    <div>
      <p>{label(data.status)}</p>
      {data.status === "failed" && <p role="alert">{data.failureReason}</p>}
      <a href={`https://sendfax.sh/f/${id}`} target="_blank" rel="noreferrer">
        Open the status page
      </a>
    </div>
  )
}

const label = (s: FaxPublic["status"]) =>
  ({
    awaiting_payment: "Waiting for payment",
    paid: "Paid -- queueing",
    queued: "Queued at the carrier",
    media_processed: "Document processed",
    sending: "Dialing",
    delivered: "Delivered",
    failed: "Failed",
    expired: "Expired"
  })[s]
```

Always link the SendFax status page too. It survives your tab closing, and it
costs one line.

---

## Using the SDK in the browser

The SDK is dependency-free and built on web-standard `fetch`, `FormData` and
`Blob` (`sdk/src/client.ts:3-6`), so it runs in browsers with no polyfills:
`atob` and `TextDecoder` are both native there, which is exactly the pair that
needs shimming on React Native.

Point `baseUrl` at your proxy and everything works, because the SDK builds
paths as `${baseUrl}/api/v1/faxes` -- so mount your proxy to match:

```ts
// app/api/v1/faxes/route.ts, app/api/v1/faxes/[id]/route.ts in YOUR app,
// each proxying the same path upstream. Then:
import { SendFax, PaymentRequiredError } from "sendfax"

const client = new SendFax({ baseUrl: window.location.origin })
```

If you would rather keep your proxy at `/api/fax/*`, inject a `fetch` that
rewrites the path instead of renaming your routes:

```ts
const client = new SendFax({
  baseUrl: window.location.origin,
  fetch: (input, init) => {
    const url = new URL(typeof input === "string" ? input : input.toString())
    url.pathname = url.pathname
      .replace(/^\/api\/v1\/faxes\/(.+)$/, "/api/fax/status/$1")
      .replace(/^\/api\/v1\/faxes$/, "/api/fax/send")
    return fetch(url, init)
  }
})
```

### Flow A with the SDK

```ts
try {
  const receipt = await client.send({ to, document: file })   // a File is a Blob
  return receipt.id
} catch (err) {
  if (!(err instanceof PaymentRequiredError)) throw err
  // No handler configured -> this is the fork, not a failure.
  const up = await fetch("/api/fax/upload", { method: "POST", body: form })
    .then((r) => r.json())
  const { url } = await fetch("/api/fax/checkout", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ id: up.id })
  }).then((r) => r.json())
  window.location.href = url
}
```

### Flow B with the SDK

Two adaptations, both documented in full at
[react-native.md](/guides/react-native.md) -- they are runtime-independent and
apply identically here:

1. **`parseChallenge` does not read the deposit headers**
   (`sdk/src/challenge.ts:92-125`), so `challenge.recipient` is the service's
   *static* address (`lib/runtime/index.ts:185`), not the per-request deposit
   address. Sending there loses the money. Wrap `fetch` to capture
   `x-payment-deposit-*` off the 402 -- which is also why your proxy must
   forward those headers.
2. **`send()` retries exactly once** (`sdk/src/client.ts:117-127`) and a
   transfer takes longer than one round trip to be seen, so wrap `send()` in an
   outer retry loop. Identical requests reuse the same PaymentIntent for 15
   minutes (`lib/payments/deposit.ts:50, 94-108`), so this is not a double
   charge.

Given a browser already has wagmi wired, the hand-rolled `useWalletFax` above is
usually less code than adapting the SDK's handler contract. Use the SDK for
Flow A and for `waitForDelivery`; use raw `fetch` for the 402 dance.

---

## Server components and caching

- **Status is never cacheable.** `fetch(..., { cache: "no-store" })` in the
  proxy, `cache-control: no-store` on the response. A cached `queued` that
  refuses to become `delivered` is a confusing bug.
- **Do not fetch fax status in a server component.** It changes every few
  seconds; that is a client concern. Render the shell on the server and poll
  from the client.
- **`export const runtime`** is your choice. The proxies are I/O only, so edge
  is a fine default; use `nodejs` if you add a server-side wallet (viem works on
  both, but key handling is easier to reason about on Node).
- **Route handlers must be dynamic.** Any handler reading a request body is,
  automatically. If you add a `GET` proxy without params, set
  `export const dynamic = "force-dynamic"` so it is not statically evaluated at
  build time.

---

## 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. No deposit address is minted; nothing is charged. Show the
server's `detail` verbatim: *"SendFax currently delivers to the United States,
Canada, Mexico, and Saint Pierre & Miquelon."* (`lib/domain/fax.ts:84-85`).

**A `+1` number can still fail at the carrier.** `+1` is the whole NANP, so
`+1876` passes validation and fails at Telnyx (`lib/domain/fax.ts:87-97`). On a
Stripe-settled payment -- including every deposit-mode payment -- 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`. 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 it, a new address is minted and the earlier
transfer is stranded. In a browser the realistic way to hit this is a
backgrounded tab throttling your retry loop, or a user who approves the wallet
popup and then wanders off. Persist `paymentIntent` to `localStorage` before
signing.

**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`). A double-clicked button is safe.

**`processing`, not `succeeded`, is the settlement bar**
(`lib/payments/deposit.ts:78-83`). Your 202 can arrive before deep confirmation.

**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 shows both buttons, show both
prices -- a one-page fax is $0.05 or $0.50 depending on which one they press.

**Uploads are not resumable.** A 20 MB PDF over a flaky connection will
sometimes fail outright. Show progress with `XMLHttpRequest`'s `upload.onprogress`
if that matters -- `fetch` still has no upload progress event.

---

## Cross-references

- [README.md](/guides/index.md) -- the chooser and the decision matrix
- [usdc.md](/guides/usdc.md) -- the settlement mechanics in protocol terms
- [react-native.md](/guides/react-native.md) -- the SDK's `PaymentHandler` adaptations in full
- [curl.md](/guides/curl.md) -- the same flow with no framework at all
- [`sdk/README.md`](https://github.com/DanielSinclair/sendfax) -- full SDK reference
- <https://sendfax.sh/openapi.json> | <https://sendfax.sh/docs>
