# Sending a fax with TypeScript

The `sendfax` npm package is the official SDK: zero runtime dependencies, ESM +
CJS, ~2.5 KB min+gz, and built on web-standard `fetch`, `FormData` and `Blob` --
so it runs unmodified on Node 18+, Bun, Deno, Cloudflare Workers, and in
browsers.

This guide covers the SDK end to end, wiring a `PaymentHandler` to a local key
with viem, and the plain-`fetch` version for when you would rather not take a
dependency at all.

```sh
npm install sendfax        # or: bun add sendfax / deno add npm:sendfax
```

- SDK source: [`sdk/src/`](https://github.com/DanielSinclair/sendfax) | reference: [`sdk/README.md`](https://github.com/DanielSinclair/sendfax)
- Wire format: [http.md](/guides/http.md) | settlement: [usdc.md](/guides/usdc.md)

---

## The two flows

```
FLOW A -- no wallet
  new SendFax()                       [no PaymentHandler]
    +-> send() throws PaymentRequiredError   <-- the fork, not a failure
    +-> POST /api/v1/uploads   (plain fetch; the SDK has no upload method)
    +-> POST /api/v1/checkout  -> { url }
    +-> open the URL in a browser
    +-> client.waitForDelivery(id)  ->  delivered

FLOW B -- wallet
  new SendFax({ fetch: capturing, payment: handler })
    +-> send() -> 402 -> handler pays -> retry
    |     handler: viem sends exact USDC on Base to the deposit address
    +-> outer loop re-calls send() until 202   (see "One retry is not enough")
    +-> client.waitForDelivery(id)  ->  delivered
```

## Which payment path am I in?

**Can this process move USDC on Base?** A private key in the environment, an
embedded signer, a custodial API -- Flow B. Nothing -- Flow A. In SDK terms the
choice is one constructor option: pass `payment` and `send()` settles inline;
omit it and `send()` throws `PaymentRequiredError`, which is your cue to run the
Stripe flow (`sdk/src/client.ts:113-128`).

---

## Runtime support

| Runtime | Works | Notes |
|---|---|---|
| Node 18+ | yes | `engines.node: ">=18"` in `sdk/package.json`. Global `fetch`, `FormData`, `Blob`, `atob`, `TextDecoder` are all native |
| Node 16 and below | no | No global `fetch`. Pass one in via `new SendFax({ fetch })` if you must |
| Bun | yes | Everything native |
| Deno | yes | `import { SendFax } from "npm:sendfax"` |
| Cloudflare Workers | yes | See [cloudflare-workers.md](/guides/cloudflare-workers.md) |
| Browsers | yes | See [nextjs.md](/guides/nextjs.md) -- the interesting part there is CORS, not the SDK |
| React Native / Hermes | with three shims | `TextDecoder`, `Blob`-from-bytes, and the `FormData` filename. See [react-native.md](/guides/react-native.md) |

The SDK uses no crypto APIs at all -- verified across `sdk/src`. Wallet libraries
do, but that is their dependency, not the SDK's.

---

## The quickest version

```ts
import { SendFax } from "sendfax"
import { readFile } from "node:fs/promises"

const client = new SendFax({ payment: myHandler })   // see below

const receipt = await client.send({
  to: "+14155550123",
  document: await readFile("invoice.pdf")            // Uint8Array | ArrayBuffer | Blob
})
// { id, pageCount, amountUsdMicros, statusUrl }

const fax = await client.waitForDelivery(receipt.id, {
  onStatus: (f) => console.log(f.status)             // queued -> sending -> delivered
})
console.log(fax.status)                              // "delivered"
```

### The API surface

| Method | Returns | Notes |
|---|---|---|
| `new SendFax({ baseUrl?, fetch?, payment? })` | | `baseUrl` defaults to `https://sendfax.sh` |
| `send({ to, document, filename? })` | `SendFaxResult` | Sends multipart; on 402 with a handler, pays and retries **once** |
| `get(id)` | `FaxPublic` | Free status lookup |
| `waitForDelivery(id, { intervalMs?, timeoutMs?, signal?, onStatus? })` | `FaxPublic` | Polls to a terminal state |

`waitForDelivery` defaults to a 1500 ms interval and a 120 s timeout
(`sdk/src/client.ts:162-163`). It resolves on `delivered`, and rejects with
`FaxFailedError` on `failed`/`expired` (carrying `failureReason`) or a
`SendFaxError` on timeout or abort.

---

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

## Flow A -- no wallet: catch, then Stripe

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

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

```ts
import { SendFax, PaymentRequiredError } from "sendfax"
import { readFile } from "node:fs/promises"

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

async function sendWithCard(path: string, to: string) {
  const bytes = await readFile(path)

  try {
    const receipt = await client.send({ to, document: bytes })
    return receipt.id                        // a handler was configured after all
  } catch (err) {
    if (!(err instanceof PaymentRequiredError)) throw err

    // --- the fork ---------------------------------------------------------
    const form = new FormData()
    form.append("document", new Blob([bytes], { type: "application/pdf" }), "document.pdf")
    form.append("to", to)

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

    const co = await fetch(`${BASE}/api/v1/checkout`, {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ id })
    })
    if (!co.ok) throw await problem(co)
    const { url } = (await co.json()) as { url: string }

    console.log(`${pageCount} page(s), $${(amountCents / 100).toFixed(2)}`)
    console.log(`Pay here: ${url}`)
    return id
  }
}

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

Then poll with the SDK, which works regardless of how payment happened:

```ts
const id = await sendWithCard("invoice.pdf", "+14155550123")
const fax = await client.waitForDelivery(id, { timeoutMs: 600_000 })
```

Nothing tells you when the card payment lands -- Checkout redirects to
`https://sendfax.sh/f/{id}?paid=1` (`lib/payments/stripe.ts:139`), not to you.
Polling is the mechanism, which is why `waitForDelivery` gets a long timeout
here.

---

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

## Flow B -- wallet: a PaymentHandler with viem

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.

Mapping that onto deposit-mode settlement takes three adaptations. They are
workarounds for real gaps, so they are worth understanding rather than pasting.

```sh
npm install viem
```

### Adaptation 1: capture the deposit headers

`parseChallenge` reads only `WWW-Authenticate` and `PAYMENT-REQUIRED`
(`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
(`lib/runtime/index.ts:185`), not the per-request deposit address.
**Sending to `challenge.recipient` loses the money.**

The SDK's `fetch` injection point is the fix:

```ts
const USDC_BASE = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" as const

export type DepositQuote = {
  network: string
  address: `0x${string}`
  token: `0x${string}`
  amountDisplay: string      // "0.050000" -- for logs and humans
  amountBaseUnits: bigint    // 50000n -- for the transfer
  paymentIntent: string      // "pi_..." -- persist before paying
}

/** Captures what the SDK's challenge parser drops. gate.ts:219-234 */
export function depositCapture() {
  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: 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. 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`), but deposit
mode has no credential -- the retry itself settles. 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`).

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`) -- inert in deposit mode, but if the
server is ever reconfigured with a facilitator they would be read as a real
credential and fail verification. `x-sendfax-deposit-tx` carries the transaction
hash, is useful in a support ticket, and can never be mistaken for one.

```ts
import { createWalletClient, http, encodeFunctionData, erc20Abi } from "viem"
import { privateKeyToAccount } from "viem/accounts"
import { base } from "viem/chains"
import type { PaymentHandler } from "sendfax"

export function depositHandler(
  quoteOf: () => DepositQuote | null,
  opts: { privateKey: `0x${string}`; maxMicros: bigint; rpcUrl?: string }
): PaymentHandler {
  const account = privateKeyToAccount(opts.privateKey)
  const wallet = createWalletClient({ account, chain: base, transport: http(opts.rpcUrl) })

  let fundedIntent: string | null = null
  let txHash = "pending"

  return async () => {
    const quote = quoteOf()
    if (!quote) throw new Error("402 carried no x-payment-deposit-* headers")

    // The outer loop calls send() repeatedly, so the handler runs more than
    // once. Pay per PaymentIntent, not per attempt.
    if (fundedIntent !== quote.paymentIntent) {
      // Enforce limits in code. The amount arrived over the wire from a remote
      // party -- which is still true when the remote party is us.
      if (quote.network !== "base") throw new Error(`unexpected network ${quote.network}`)
      if (quote.token.toLowerCase() !== USDC_BASE.toLowerCase())
        throw new Error(`unexpected token ${quote.token}`)
      if (quote.amountBaseUnits > opts.maxMicros)
        throw new Error(`charge ${quote.amountDisplay} exceeds the configured ceiling`)

      txHash = await wallet.sendTransaction({
        to: quote.token,                                 // the USDC contract
        data: encodeFunctionData({
          abi: erc20Abi,
          functionName: "transfer",
          args: [quote.address, quote.amountBaseUnits]   // the deposit address
        })
      })
      fundedIntent = quote.paymentIntent
    }

    // Deposit mode has no credential: the retry itself settles, and the gate
    // never reads request headers on this path. Informational only.
    return { headerName: "x-sendfax-deposit-tx", headerValue: txHash }
  }
}
```

### Adaptation 3: one retry is not enough

`send()` pays and retries exactly once, then throws `PaymentRequiredError` if
that retry is still a 402 (`sdk/src/client.ts:117-127`). A USDC transfer on Base
needs 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`). Identical `send()` calls are not a double
charge, and the handler's `fundedIntent` guard means the wallet pays once.

```ts
import { SendFax, PaymentRequiredError, type SendFaxResult } from "sendfax"

export async function sendWithWallet(
  document: Uint8Array,
  to: string,
  opts: { privateKey: `0x${string}`; maxMicros?: bigint }
): Promise<SendFaxResult> {
  const { capturingFetch, quote } = depositCapture()
  const client = new SendFax({
    fetch: capturingFetch,
    payment: depositHandler(quote, {
      privateKey: opts.privateKey,
      maxMicros: opts.maxMicros ?? 1_000_000n         // $1.00
    })
  })

  // 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
    } 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")
}
```

Put it together:

```ts
const receipt = await sendWithWallet(await readFile("invoice.pdf"), "+14155550123", {
  privateKey: process.env.SENDFAX_PRIVATE_KEY as `0x${string}`
})
const fax = await new SendFax().waitForDelivery(receipt.id, {
  onStatus: (f) => console.error(f.status)
})
```

### Wallet funding

The key needs USDC on Base for the fax and a little ETH on Base for gas. Gas is
typically a fraction of a cent, but a zero-ETH wallet cannot transfer USDC at all
-- the most common first-run failure, and it looks like a SendFax problem when it
is not. Check both balances before you trust a cron job with it.

---

## The mock handler, for development

Against a dev server in `FAX_MODE=mock`, `mockPaymentHandler()` sends the
`x-mock-payment` bypass 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 dev = process.env.NODE_ENV !== "production"
const client = new SendFax({
  baseUrl: dev ? "http://localhost:3001" : "https://sendfax.sh",
  payment: dev ? mockPaymentHandler() : realHandler
})
```

Mock mode also drives the status lifecycle on read -- one step per `GET`, about 3
seconds apart (`web/app/api/v1/faxes/[id]/route.ts:24-32`) -- so
`waitForDelivery` reaches `delivered` in roughly 15 seconds. Never ship
`mockPaymentHandler` to production: it pays nothing and every send still 402s.

---

## Errors

Every rejection is a typed subclass of `SendFaxError`, carrying `.status` and the
parsed `.problem` body:

```ts
import {
  PaymentRequiredError,
  InvalidDestinationError,
  InvalidDocumentError,
  NotFoundError,
  FaxFailedError,
  ApiError
} from "sendfax"

try {
  await client.send({ to, document })
} catch (err) {
  if (err instanceof InvalidDestinationError) {
    // 422. Not E.164, or outside US/CA/MX/PM. `err.problem.detail` is the
    // exact sentence to show. lib/domain/fax.ts:84-85
    console.error(err.problem?.detail ?? err.message)
  } else if (err instanceof InvalidDocumentError) {
    // 400 / 413 / 422: not a PDF, encrypted, zero pages, or over 20 MB.
  } else if (err instanceof PaymentRequiredError) {
    // No handler, or the handler could not satisfy the challenge.
    console.error(err.challenge.amountBaseUnits, err.challenge.tokenAddress)
  } else if (err instanceof ApiError) {
    // Any other non-2xx: inspect err.status / err.problem.
  }
}
```

| Error | When |
|---|---|
| `PaymentRequiredError` | 402 -- carries the parsed `challenge` |
| `InvalidDocumentError` | bad/encrypted/empty/too-large PDF (400 / 413 / 422) |
| `InvalidDestinationError` | 422 with a destination-related title |
| `NotFoundError` | 404 -- unknown fax id |
| `FaxFailedError` | `waitForDelivery` saw `failed`/`expired`; has `failureReason` |
| `ApiError` | any other non-2xx |

Note that both 422 cases share a status code, so the SDK routes on the problem
`title` (`sdk/src/errors.ts:117-120`). If you write your own client, do the same.

---

## Without the SDK

The SDK is 2.5 KB and dependency-free, so "avoid the dependency" is rarely worth
it -- but the whole flow is short enough that here it is, in plain `fetch`, for
when you want to see it or cannot add a package.

```ts
import { readFile } from "node:fs/promises"

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

type Receipt = { id: string; pageCount: number; amountUsdMicros: number; statusUrl: string }
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"]

async function sendFax(path: string, to: string, pay: (q: Quote) => Promise<void>) {
  const bytes = await readFile(path)

  // Build fresh each attempt; the BYTES must be identical, the envelope need not be.
  const body = () => {
    const form = new FormData()
    form.append("document", new Blob([bytes], { type: "application/pdf" }), "document.pdf")
    form.append("to", to)
    return form
  }

  let res = await fetch(`${BASE}/api/v1/faxes`, { method: "POST", body: body() })
  if (res.status === 202) return (await res.json()) as Receipt
  if (res.status !== 402) throw await problem(res)

  const quote = readQuote(res)
  if (!quote) throw new Error("402 carried no deposit headers")
  await pay(quote)

  const deadline = Date.now() + 14 * 60_000
  let delay = 3000
  while (Date.now() < deadline) {
    await new Promise((r) => setTimeout(r, delay))
    res = await fetch(`${BASE}/api/v1/faxes`, { method: "POST", body: body() })
    if (res.status === 202) return (await res.json()) as Receipt
    if (res.status !== 402) throw await problem(res)
    delay = Math.min(Math.round(delay * 1.5), 15_000)
  }
  throw new Error("deposit not detected within the 15-minute window")
}

type Quote = { address: string; token: string; network: string; amountBaseUnits: bigint }

function readQuote(res: Response): Quote | 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 {
    address,
    token: h.get("x-payment-deposit-token") ?? "",
    network: h.get("x-payment-deposit-network") ?? "base",
    amountBaseUnits: BigInt(micros)
  }
}

async function waitForDelivery(id: string, timeoutMs = 300_000): Promise<FaxPublic> {
  const deadline = Date.now() + timeoutMs
  while (Date.now() < deadline) {
    const res = await fetch(`${BASE}/api/v1/faxes/${encodeURIComponent(id)}`)
    if (!res.ok) throw await problem(res)
    const fax = (await res.json()) as FaxPublic
    if (TERMINAL.includes(fax.status)) return fax
    await new Promise((r) => setTimeout(r, 2000))
  }
  throw new Error(`timed out waiting for ${id}`)
}

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

What you give up by hand-rolling it: the error taxonomy, the challenge parser,
abort-signal support in the poll loop, and the conformance fixtures the SDK's
tests assert against.

---

## A complete CLI

Putting Flow A and Flow B behind one command, with env-var config and exit codes.

| Variable | Required | Meaning |
|---|---|---|
| `SENDFAX_PRIVATE_KEY` | for autonomous mode | `0x...` EVM key holding USDC and a little ETH on Base |
| `SENDFAX_API` | no | Defaults to `https://sendfax.sh` |
| `SENDFAX_MAX_USD` | no | Spend ceiling per fax; defaults to `1.00` |
| `SENDFAX_RPC_URL` | no | Base RPC; defaults to viem's public endpoint |
| `SENDFAX_MOCK` | no | `1` uses the dev bypass header (mock servers only) |

Never put a key on the command line -- it lands in shell history and in `ps`.

```ts
#!/usr/bin/env bun
/**
 * sendfax <file.pdf> <+E164>
 * Exit: 0 delivered - 2 usage - 3 bad destination - 4 bad document
 *       5 payment not settled - 6 delivery failed - 1 other
 */
import { readFile } from "node:fs/promises"
import {
  SendFax,
  PaymentRequiredError,
  InvalidDestinationError,
  InvalidDocumentError,
  FaxFailedError,
  mockPaymentHandler
} from "sendfax"

const API = process.env.SENDFAX_API ?? "https://sendfax.sh"
const MAX = BigInt(Math.round(Number(process.env.SENDFAX_MAX_USD ?? "1.00") * 1_000_000))

const [file, to] = process.argv.slice(2)
if (!file || !to) {
  console.error("usage: sendfax <file.pdf> <+E164>")
  process.exit(2)
}

const bytes = await readFile(file)
const { capturingFetch, quote } = depositCapture()

const payment =
  process.env.SENDFAX_MOCK === "1"
    ? mockPaymentHandler()
    : process.env.SENDFAX_PRIVATE_KEY
      ? depositHandler(quote, {
          privateKey: process.env.SENDFAX_PRIVATE_KEY as `0x${string}`,
          maxMicros: MAX,
          rpcUrl: process.env.SENDFAX_RPC_URL
        })
      : undefined

const client = new SendFax({ baseUrl: API, fetch: capturingFetch, payment })

const deadline = Date.now() + 14 * 60_000
let delay = 3000
let receipt

while (!receipt) {
  try {
    receipt = await client.send({ to, document: bytes })
  } catch (err) {
    if (err instanceof InvalidDestinationError) {
      console.error(err.problem?.detail ?? err.message)
      process.exit(3)
    }
    if (err instanceof InvalidDocumentError) {
      console.error(err.problem?.detail ?? err.message)
      process.exit(4)
    }
    if (!(err instanceof PaymentRequiredError)) throw err
    if (!payment) {
      const q = quote()
      console.error(
        q
          ? `payment required: send exactly ${q.amountDisplay} USDC on ${q.network} to ${q.address}`
          : `payment required: ${err.challenge.amountBaseUnits} base units`
      )
      process.exit(5)
    }
    if (Date.now() > deadline) {
      console.error("deposit not detected within the 15-minute window")
      process.exit(5)
    }
    await new Promise((r) => setTimeout(r, delay))
    delay = Math.min(Math.round(delay * 1.5), 15_000)
  }
}

console.error(
  `accepted ${receipt.id} (${receipt.pageCount} page(s), ` +
    `$${(receipt.amountUsdMicros / 1_000_000).toFixed(2)})`
)
console.error(receipt.statusUrl)

try {
  const fax = await client.waitForDelivery(receipt.id, {
    intervalMs: 2000,
    timeoutMs: 600_000,
    onStatus: (f) => console.error(f.status)
  })
  console.log(JSON.stringify(fax))       // stdout: the machine-readable result
  process.exit(0)
} catch (err) {
  if (err instanceof FaxFailedError) {
    console.error(`failed: ${err.failureReason ?? "unknown"}`)
    console.log(JSON.stringify(err.fax))
    process.exit(6)
  }
  throw err
}
```

```sh
export SENDFAX_PRIVATE_KEY=0x...        # from a secret manager, not your shell history
export SENDFAX_MAX_USD=0.50
bun sendfax.ts invoice.pdf +14155550123
# accepted 133718a5-... (1 page(s), $0.05)
# https://sendfax.sh/f/133718a5-...
# queued
# sending
# delivered
# {"id":"133718a5-...","status":"delivered",...}
```

Status chatter goes to stderr and the final `FaxPublic` JSON to stdout, so
`bun sendfax.ts doc.pdf +1415... | jq -r .terminalAt` works in a pipeline.

For Node, swap the shebang for `#!/usr/bin/env node` and run through `tsx`, or
compile first. Node 18+ has global `fetch`, `FormData` and `Blob`, which is
everything the SDK needs (`sdk/src/client.ts:3-6`).

---

## Edge cases

**`422` costs nothing.** `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. Surface `err.problem?.detail` verbatim.

**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 -- which includes 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** (`lib/payments/deposit.ts:50`).
Past it a new address is minted and the earlier transfer is stranded. For
anything unattended, log `quote.paymentIntent` before broadcasting.

**Retrying after 202 is idempotent** (`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 precede deep confirmation.

**`document` accepts `Uint8Array | ArrayBuffer | Blob`**
(`sdk/src/client.ts:40`) and is buffered internally so the request can be
retried. `filename` defaults to `"document.pdf"`.

**Pricing differs by rail.** $0.05/page by wallet; `max($0.50, $0.05 x pages)` by
card (`lib/domain/pricing.ts:16, 23`).

---

## Cross-references

- [http.md](/guides/http.md) -- the wire format this SDK is a projection of
- [curl.md](/guides/curl.md) -- the same flow with no runtime at all
- [cloudflare-workers.md](/guides/cloudflare-workers.md) -- the SDK on workerd
- [nextjs.md](/guides/nextjs.md) -- the same SDK in a browser, plus CORS
- [`sdk/README.md`](https://github.com/DanielSinclair/sendfax) -- full SDK reference
- <https://sendfax.sh/openapi.json> | <https://sendfax.sh/docs>
