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

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

The two flows

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

CallWhat the browser doesWhat you see
POST /api/v1/uploads or /faxes with FormDatamultipart/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/jsonNot 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.


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.


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