# Platform integration guides

How to send a fax from a real app. One API contract, three kinds of guide.

**Protocol** -- what the wire looks like, independent of any platform.

| Guide | Covers |
|---|---|
| [http.md](/guides/http.md) | The wire-format reference: every request and response, the multipart shape, both 402 dialects verbatim, idempotent retry semantics, the status lifecycle, and the problem+json error table. The page every other guide cites instead of repeating itself |
| [usdc.md](/guides/usdc.md) | Paying with USDC and stablecoins: full 402 anatomy, the exact-amount rule, Base + the USDC contract, direct-transfer semantics, settlement timing, refunds, an end-to-end curl walkthrough |
| [mpp.md](/guides/mpp.md) | The MPP dialect specifically: the `WWW-Authenticate: Payment` challenge decoded field by field, how MPP clients pay it, its relationship to x402, and what settles money today |

**Platforms** -- building it into an app.

| Guide | Platform | Language / runtime | Uses the SDK? |
|---|---|---|---|
| [curl.md](/guides/curl.md) | terminal, scripts, cron | bash + curl + jq | no -- no dependencies at all |
| [typescript.md](/guides/typescript.md) | server, CLI, anywhere JS runs | Node 18+ / Bun / Deno | yes -- `sendfax` npm |
| [nextjs.md](/guides/nextjs.md) | React / Next.js web app | TypeScript, browser | yes -- runs unmodified |
| [cloudflare-workers.md](/guides/cloudflare-workers.md) | Cloudflare Workers | TypeScript, workerd | yes -- no shims needed |
| [macos.md](/guides/macos.md) | native macOS (SwiftUI / AppKit) | Swift, URLSession | no -- raw HTTP |
| [ios.md](/guides/ios.md) | native iOS | Swift, URLSession | no -- raw HTTP |
| [react-native.md](/guides/react-native.md) | React Native / Expo | TypeScript, Hermes | yes -- `sendfax` npm |

[cli.md](/guides/cli.md) is a pointer: it used to hold both the curl and the Node halves,
which now live in [curl.md](/guides/curl.md) and [typescript.md](/guides/typescript.md).

**Agents** -- connecting an existing assistant.

| Guide | Agent | Connect via |
|---|---|---|
| [agents/claude.md](/guides/agents/claude.md) | Claude Code, Claude Desktop, claude.ai | `claude mcp add`, or the skill, or a custom connector |
| [agents/chatgpt.md](/guides/agents/chatgpt.md) | ChatGPT (web, Work mode) | developer-mode custom MCP server |
| [agents/codex.md](/guides/agents/codex.md) | OpenAI Codex CLI | `codex mcp add --url`, plus `AGENTS.md` |
| [agents/hermes.md](/guides/agents/hermes.md) | Hermes Agent (Nous Research) | `hermes mcp add --url`, `~/.hermes/config.yaml` |
| [agents/openclaw.md](/guides/agents/openclaw.md) | OpenClaw | `openclaw mcp add --transport streamable-http` |

Every agent guide names one shared limitation up front: the MCP `send_fax` tool
forwards only three headers from the 402 (`mcp/tools.ts:207-210`), so the
per-request deposit address never reaches the agent. Each guide documents the
two ways through it -- the HTTP API via a shell tool, or Stripe Checkout.

Everything here is verified against this repo. Each guide cites the file that
defines the behavior it documents, so you can check a claim without trusting the
prose. Public contract: <https://sendfax.sh/openapi.json> |
<https://sendfax.sh/docs> | <https://sendfax.sh/quickstart.md>.

---

## The two flows

There are exactly two ways to pay for a fax, and which one you use is decided by
one question: **can your app move USDC on Base?**

```
                          Can this app move USDC on Base?
                                      |
                +---------------------+---------------------+
                | YES                                       | NO
                v                                           v
        AGENT FLOW (402)                            HUMAN FLOW (Stripe)
        POST /api/v1/faxes                          POST /api/v1/uploads
        -> 402 + deposit headers                    -> { id, pageCount, amountCents }
        -> send exact USDC on Base                  POST /api/v1/checkout { id }
        -> retry identical request                  -> { url }  (hosted Checkout)
        -> 202 { id, statusUrl }                    -> open url in a browser
                |                                           |
                +---------------------+---------------------+
                                      |
                                      v
                          GET /api/v1/faxes/{id}
                          poll to a terminal status
                          (delivered | failed | expired)
                          human page: https://sendfax.sh/f/{id}
```

Both flows converge on the same free status endpoint and the same status page.
Neither needs an account or an API key. Payment is the credential.

---

## The universal payment-decision framework

State this once; every guide refers back to it.

**Wallet available -> pay the 402 directly.** An MPP-capable wallet reads the
`WWW-Authenticate: Payment` challenge and pays it. Any ordinary Ethereum wallet
-- WalletConnect/Reown, an embedded signer, a hardware key, a local private key --
does not need to understand MPP at all: it sends the exact USDC amount on Base
to the per-request deposit address advertised in the `x-payment-deposit-*`
response headers, as a plain ERC-20 `transfer`. Then it retries the identical
request until the server answers `202`.

**No wallet -> Stripe hosted Checkout.** This is the human flow
(`/api/v1/uploads` -> `/api/v1/checkout` -> open the returned URL). It is also the
correct *fallback* for a wallet-capable app whose wallet is not connected right
now, or whose user would rather pay by card.

**The SDK encodes exactly this choice.** Configure a `PaymentHandler` and
`SendFax.send()` pays inline; omit it and `send()` throws
`PaymentRequiredError`, which is your cue to fall back to Stripe. There is no
third option and no hidden mode.

```ts
// with a wallet
const client = new SendFax({ payment: myHandler })
await client.send({ to, document })          // pays the 402 inline

// without one
const client = new SendFax()                  // no handler
try   { await client.send({ to, document }) }
catch (e) { if (e instanceof PaymentRequiredError) await stripeFallback() }
```

Defined in `sdk/src/client.ts:113-128` (the 402 branch) and
`sdk/src/payment.ts:45-48` (the handler type).

---

## Decision matrix

| Platform | Wallet availability | Recommended flow | Guide |
|---|---|---|---|
| React / Next.js web app | none | Human: proxy route -> uploads -> checkout -> `window.location` -> poll with SWR | [nextjs.md](/guides/nextjs.md) |
| React / Next.js web app | wagmi / WalletConnect | Agent: 402 through your proxy -> `writeContract` -> retry to 202 | [nextjs.md](/guides/nextjs.md) |
| Browser SPA (no framework) | either | Same two flows; the SDK runs unmodified in browsers | [nextjs.md](/guides/nextjs.md) |
| macOS app | none | Human: uploads -> checkout -> `NSWorkspace.open` -> poll | [macos.md](/guides/macos.md) |
| macOS app | WalletConnect / Reown AppKit | Agent: 402 -> USDC transfer -> retry to 202 | [macos.md](/guides/macos.md) |
| macOS daemon / menu bar agent | local private key | Agent, headless -- same as the TypeScript signer | [typescript.md](/guides/typescript.md) |
| iOS app | none | Human: uploads -> checkout -> `SFSafariViewController` -> poll in foreground | [ios.md](/guides/ios.md) |
| iOS app | Reown AppKit iOS | Agent: 402 -> USDC transfer -> retry to 202 | [ios.md](/guides/ios.md) |
| React Native / Expo | none | SDK `send()` -> catch `PaymentRequiredError` -> Stripe fallback via `fetch` | [react-native.md](/guides/react-native.md) |
| React Native / Expo | MPP-capable wallet SDK | SDK `send()` with a signing `PaymentHandler` | [react-native.md](/guides/react-native.md) |
| React Native / Expo | WalletConnect / wagmi | SDK `send()` with a deposit-transfer `PaymentHandler` | [react-native.md](/guides/react-native.md) |
| Terminal, no deps | a human with any wallet | curl: print deposit address + QR, poll | [curl.md](/guides/curl.md) |
| Terminal, no deps | none | curl: print the Stripe Checkout URL, poll | [curl.md](/guides/curl.md) |
| Server / cron / CI | local EVM key (viem) | SDK + viem handler, fully autonomous | [typescript.md](/guides/typescript.md) |
| Cloudflare Worker | key in a `wrangler secret` | Queue consumer pays the 402 and retries | [cloudflare-workers.md](/guides/cloudflare-workers.md) |
| Cloudflare Worker | none | Return a Stripe Checkout URL to your caller | [cloudflare-workers.md](/guides/cloudflare-workers.md) |
| Claude Code | shell + a key you control | MCP or skill; Claude reads the 402 over HTTP and pays | [agents/claude.md](/guides/agents/claude.md) |
| Claude Desktop / claude.ai | none | Custom connector; settle via Stripe Checkout | [agents/claude.md](/guides/agents/claude.md) |
| ChatGPT (Business/Enterprise/Edu, web) | none -- no wallet, no shell | Developer-mode MCP server; settle via Stripe Checkout | [agents/chatgpt.md](/guides/agents/chatgpt.md) |
| Codex CLI | shell + a key you control | `codex mcp add --url`, `AGENTS.md` policy | [agents/codex.md](/guides/agents/codex.md) |
| Hermes Agent | shell + a key you control | `hermes mcp add`, or QR to your phone over Telegram | [agents/hermes.md](/guides/agents/hermes.md) |
| OpenClaw | shell + a key you control | `openclaw mcp add`, or QR into the chat thread | [agents/openclaw.md](/guides/agents/openclaw.md) |
| Any other agent with MCP | varies | Point it at `https://mcp.sendfax.sh/mcp`; settle per [usdc.md](/guides/usdc.md) | [usdc.md](/guides/usdc.md) |
| Anything else | any | Read the wire format and implement it directly | [http.md](/guides/http.md) |

---

## Facts every guide repeats (and must agree on)

| Fact | Value | Defined in |
|---|---|---|
| Agent price | $0.05/page, i.e. `50000` USDC base units per page | `lib/domain/pricing.ts:15,19` |
| Human price | `max($0.50, $0.05 x pages)` -- Stripe's minimum charge is 50c | `lib/domain/pricing.ts:16,23` |
| Settlement token | USDC on Base, `0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913` | `lib/payments/deposit.ts:53-55` |
| Destinations | US, Canada, Mexico, Saint Pierre & Miquelon (`+1`, `+52`, `+508`) | `lib/domain/fax.ts:70-78` |
| Unsupported destination | `422` **before** any payment is requested | `web/app/api/v1/faxes/route.ts:62-64` (gate runs at line 81) |
| Document limit | unencrypted PDF, <= 20 MB | `lib/domain/pricing.ts:42` |
| Deposit address lifetime | reusable for ~15 minutes per identical request | `lib/payments/deposit.ts:50` |
| Settled at | Stripe PaymentIntent `processing` or `succeeded` | `lib/payments/deposit.ts:82-83` |
| Terminal statuses | `delivered`, `failed`, `expired` | `sdk/src/types.ts:27` |

---

## Anatomy of the 402

One response, three layers. Your client uses whichever layer it can act on.

```
HTTP/1.1 402 Payment Required
WWW-Authenticate: Payment id="...", realm="sendfax.sh", method="evm",
                  intent="charge", request="<base64url JSON>", expires="<ISO8601>"
PAYMENT-REQUIRED: <base64 x402 requirements>     # present once a facilitator is wired
x-payment-deposit-mode: stripe-deposit
x-payment-deposit-network: base
x-payment-deposit-address: 0x157a57423869bf2666d3998b1319c2263ce852bb
x-payment-deposit-token: 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913
x-payment-deposit-amount: 0.050000
x-payment-deposit-amount-micros: 50000
x-payment-deposit-payment-intent: pi_3Q...
x-payment-deposit-instructions: https://sendfax.sh/docs#stripe-deposit
Content-Type: application/problem+json
```

1. **`WWW-Authenticate: Payment`** -- the MPP challenge. Its `request` parameter
   is base64url JSON: `{ amount, currency, recipient, methodDetails: { chainId,
   decimals, credentialTypes } }`. An MPP-native wallet pays this and retries
   with a `PAYMENT` header. (`lib/payments/gate.ts:269-276`,
   `sdk/src/challenge.ts:131-157`.)
2. **`PAYMENT-REQUIRED`** -- the same charge in x402 dialect, a base64
   `{ accepts: [...] }` body. Emitted once an x402 facilitator is configured.
3. **`x-payment-deposit-*`** -- the **wired settlement path today**. A fresh
   Stripe deposit-mode PaymentIntent per request; you send the exact amount to
   `x-payment-deposit-address` and Stripe watches the chain.
   (`lib/payments/gate.ts:219-234`, `lib/payments/deposit.ts:1-34`.)

> **The trap.** `recipient` inside the MPP `request` param is the service's
> static receiving address (`X402_PAY_TO_ADDRESS`,
> `lib/runtime/index.ts:185`) -- **not** the deposit address. In deposit mode,
> sending USDC to `recipient` settles nothing. Always pay
> `x-payment-deposit-address`. Every guide's wallet flow reads that header, not
> the challenge body.

---

## Rules that apply everywhere

- **Exact amount.** Deposit mode matches on the amount to the base unit. A
  transfer of `0.049999` or `0.06` USDC cannot be auto-matched.
  (`lib/payments/deposit.ts:16-18, 78-80`.)
- **Retry the identical request.** Same `document` bytes, same `to`, no payment
  header. The server hashes `to + amount + bytes` to find your pending
  PaymentIntent; any change to any of the three mints a new address.
  (`lib/payments/deposit.ts:94-108`.)
- **Poll, don't wait.** Until Stripe sees the transfer, retries return the same
  402 with the same address. Poll every few seconds inside the 15-minute window.
- **Retrying after 202 is safe.** 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`.)
- **Never charged for a fax we cannot deliver.** Unsupported destinations are
  rejected with 422 before payment; a destination that fails carrier-side after
  a Stripe-settled payment (including the deposit rail) is auto-refunded to the
  sending wallet. Raw on-chain MPP payments have no Stripe charge to reverse and
  are not auto-refunded. (`lib/payments/refund.ts:1-30`.)
- **Status is free.** `GET /api/v1/faxes/{id}` needs no payment and no auth. The
  id is an unguessable capability.

---

## Where to go next

- New to the protocol? Start with [usdc.md](/guides/usdc.md) -- it is the whole system
  in one page, with a curl walkthrough you can run.
- Just want your assistant to fax something? Pick your agent under
  [agents/](/guides/index.md) and skip the rest.
- Building a product? Find your platform in the matrix above.

- Contract: <https://sendfax.sh/openapi.json>
- Agent quickstart: <https://sendfax.sh/quickstart.md>
- Full docs: <https://sendfax.sh/docs>
- SDK reference: [`sdk/README.md`](https://github.com/DanielSinclair/sendfax)
- MCP server: `https://mcp.sendfax.sh/mcp`
- Internals: [`docs/ARCHITECTURE.md`](https://github.com/DanielSinclair/sendfax.sh/blob/main/docs/ARCHITECTURE.md)
