# Sending a fax with USDC and stablecoins

The protocol-level guide. No platform, no SDK, no language -- just the HTTP
conversation and the on-chain transfer that settles it. If you are integrating
from something this documentation set does not cover, this is the page to read.

The short version: `POST` a PDF, get `402` with a fresh deposit address, send the
exact USDC amount to it on Base, `POST` the identical request again until it
returns `202`.

- Contract: <https://sendfax.sh/openapi.json> | docs: <https://sendfax.sh/docs>
- Platform guides: [README.md](/guides/index.md)
- The MPP dialect specifically: [mpp.md](/guides/mpp.md)

---

## The flow

```
  1. POST /api/v1/faxes            multipart: document (PDF) + to (E.164)
        |
        +--> 422 unsupported destination / 413 too large / 422 bad PDF
        |      (validated BEFORE payment -- nothing is charged, no address minted)
        |
        +--> 402 Payment Required
               WWW-Authenticate: Payment ...        (MPP dialect)
               PAYMENT-REQUIRED: <base64>           (x402 dialect)
               x-payment-deposit-address: 0x...     (<-- the one that settles)
               x-payment-deposit-amount-micros: 50000
        |
  2. ERC-20 transfer: exactly 50000 base units of USDC on Base
        |            to the deposit address, from any wallet
        |
  3. POST /api/v1/faxes            IDENTICAL request, no payment header
        |
        +--> 402  (transfer not seen yet -- same address, retry in a few seconds)
        +--> 202  { id, pageCount, amountUsdMicros, statusUrl }
        |
  4. GET /api/v1/faxes/{id}        free, no auth
        +--> queued -> media_processed -> sending -> delivered
```

## Which payment path am I in?

**Can you move USDC on Base?** Then you are on this page: pay the 402 directly.
Any wallet works -- a browser extension, a hardware key, a custodial API, a
local private key, an exchange withdrawal. Nothing needs to understand x402 or
MPP; it needs to send an ERC-20 transfer.

**Can you not?** Then you want the human rail: `POST /api/v1/uploads` ->
`POST /api/v1/checkout` -> open the returned Stripe Checkout URL. See
[README.md](/guides/index.md) for that flow. Note the price differs: $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`).

---

## Step 1: submit the fax

`POST https://sendfax.sh/api/v1/faxes` as `multipart/form-data`:

| Field | Type | Notes |
|---|---|---|
| `document` | file | Unencrypted PDF, <= 20 MB |
| `to` | string | Destination fax number in E.164 form, e.g. `+14155550123` |

```sh
curl -sS -D - -o - \
  -F document=@invoice.pdf \
  -F to=+14155550123 \
  https://sendfax.sh/api/v1/faxes
```

The first call is always unpaid and always returns `402`. Nothing about this
request is authenticated -- there are no accounts, no API keys, no bearer
tokens. Payment is the credential.

### What can go wrong before you pay

All of these are checked **before** the payment gate runs
(`web/app/api/v1/faxes/route.ts` validates at lines 56-67; the gate is invoked at
line 81), so a rejected request never mints a deposit address and never asks you
for money:

| Status | Meaning | Where |
|---|---|---|
| `400` | `document` missing or not a file part | route.ts:56-58 |
| `422` | `to` is not E.164 | route.ts:59-61 |
| `422` | `to` is E.164 but outside US/CA/MX/PM | route.ts:62-64 |
| `413` | PDF over 20 MB | route.ts:65-67 |
| `422` | PDF unreadable, encrypted, or zero pages | `lib/runtime/index.ts:262-265` |

Destinations are `+1` (the whole NANP), `+52` (Mexico), and `+508` (Saint Pierre
& Miquelon) -- `lib/domain/fax.ts:70-78`. Errors are RFC 9457 problem details:

```json
{
  "type": "about:blank",
  "title": "Unsupported destination",
  "status": 422,
  "detail": "SendFax currently delivers to the United States, Canada, Mexico, and Saint Pierre & Miquelon."
}
```

---

## Step 2: read the 402

One response carries three layers. You act on the third.

```
HTTP/1.1 402 Payment Required
WWW-Authenticate: Payment id="cibtb1AF_IjPZk5Dk_pXiSxhf-pEYBrpGjFrPemcsO8",
                  realm="sendfax.sh", method="evm", intent="charge",
                  request="eyJhbW91bnQiOiI1MDAwMCIsImN1cnJlbmN5Ijoi...",
                  expires="2026-07-27T04:57:13.917Z"
PAYMENT-REQUIRED: <base64 x402 payment requirements>
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

{
  "type": "https://paymentauth.org/problems/payment-required",
  "title": "Payment Required",
  "status": 402,
  "detail": "Payment is required.",
  "challengeId": "cibtb1AF_IjPZk5Dk_pXiSxhf-pEYBrpGjFrPemcsO8"
}
```

**Layer 1, `WWW-Authenticate: Payment`** -- the MPP challenge, issued by the real
mppx challenge machinery (`lib/payments/gate.ts:269-276`). Its `request`
parameter is base64url JSON. Decoded:

```json
{
  "amount": "50000",
  "currency": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
  "methodDetails": { "chainId": 8453, "credentialTypes": ["authorization"], "decimals": 6 },
  "recipient": "0x<the service's static receiving address>"
}
```

**Layer 2, `PAYMENT-REQUIRED`** -- the same charge in x402 dialect, a base64
`{ x402Version, error, accepts: [{ scheme, network, asset, maxAmountRequired, ... }] }`
body. Emitted once an x402 facilitator is configured.

**Layer 3, `x-payment-deposit-*`** -- the wired settlement path, and the only one
that moves money today. These are additive headers on the same challenge
(`lib/payments/gate.ts:219-234`):

| Header | Example | Use |
|---|---|---|
| `x-payment-deposit-mode` | `stripe-deposit` | confirms which settlement model |
| `x-payment-deposit-network` | `base` | the chain to send on |
| `x-payment-deposit-address` | `0x157a...52bb` | **send here** -- fresh per request |
| `x-payment-deposit-token` | `0x8335...2913` | USDC ERC-20 contract on Base |
| `x-payment-deposit-amount` | `0.050000` | human-readable amount |
| `x-payment-deposit-amount-micros` | `50000` | **exact base units** -- use this |
| `x-payment-deposit-payment-intent` | `pi_3Q...` | Stripe PaymentIntent id; keep it |
| `x-payment-deposit-instructions` | a URL | pointer to the full write-up |

### The trap

`recipient` inside the MPP challenge is the **service's static receiving
address** (`X402_PAY_TO_ADDRESS`, `lib/runtime/index.ts:185`). It is not your
deposit address. In deposit mode, USDC sent to `recipient` does not settle your
request, does not queue your fax, and cannot be matched to it automatically.

Send to `x-payment-deposit-address`. It is minted for your specific request and
appears nowhere else.

### Use the micros header, not the decimal one

`x-payment-deposit-amount` is `"0.050000"` -- for a human to read. Deposit mode
matches the transfer on the exact base-unit value
(`lib/payments/deposit.ts:16-18`), so parse `x-payment-deposit-amount-micros`
(`"50000"`) as an integer and pass it straight to the transfer. Multiplying a
parsed float by a million is how you end up one base unit short of a match that
has to be exact.

---

## Step 3: send the USDC

### The parameters

| | |
|---|---|
| Network | Base mainnet, chain id `8453` |
| Token | USDC, `0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913` (`lib/payments/deposit.ts:53-55`) |
| Decimals | 6 -- so `50000` base units = $0.05 |
| Call | ERC-20 `transfer(address,uint256)`, selector `0xa9059cbb` |
| `to` (tx) | the USDC contract |
| `to` (arg) | `x-payment-deposit-address` |
| `amount` (arg) | `x-payment-deposit-amount-micros` |
| Approvals | none. No `approve`, no `permit`, no allowance |

Raw calldata is the selector, the recipient left-padded to 32 bytes, and the
amount left-padded to 32 bytes:

```
0xa9059cbb
  000000000000000000000000157a57423869bf2666d3998b1319c2263ce852bb
  000000000000000000000000000000000000000000000000000000000000c350
```

(`0xc350` = 50000.) With viem:

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

const wallet = createWalletClient({
  account: privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`),
  chain: base,
  transport: http()
})

const hash = await wallet.sendTransaction({
  to: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",     // USDC contract
  data: encodeFunctionData({
    abi: erc20Abi,
    functionName: "transfer",
    args: [depositAddress, 50_000n]                      // address, base units
  })
})
```

Or, from a wallet that scans EIP-681:

```
ethereum:0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913@8453/transfer?address=0x157a57423869bf2666d3998b1319c2263ce852bb&uint256=50000
```

Support for the token-transfer form of EIP-681 varies by wallet -- always show
the address and amount as text alongside any QR code.

### Why there is no signature

The classic x402 "exact" scheme has the client sign an EIP-3009
`transferWithAuthorization` and a *facilitator* broadcast it. That requires
trusting a third party to relay your authorization, and on mainnet the usual
facilitator is Coinbase CDP.

SendFax uses Stripe's deposit mode instead
(`lib/payments/deposit.ts:1-34`). Stripe creates a PaymentIntent with
`payment_method_options[crypto][mode]=deposit` and returns a unique on-chain
address. You send a normal transfer. Stripe watches the chain, detects it, moves
the PaymentIntent `requires_action -> processing -> succeeded`, and captures.
There is no facilitator, no relayer, no signed authorization, and no third party
between your wallet and the address.

The honest trade-off, stated the same way the code states it: **an x402 client
library that only knows how to sign an EIP-3009 authorization cannot pay this
challenge.** The signature has nowhere to go. You must send a plain transfer.
The 402 says so in `x-payment-deposit-mode`, and the MPP/x402 challenge is still
issued alongside for wire-format discovery.

### Exactness

The amount must match to the base unit. Deposit mode identifies your payment by
what arrived at that address; an amount that is not the expected one cannot be
auto-matched (`lib/payments/deposit.ts:16-18, 78-80`). Do not round up "to be
safe", do not add a tip, do not send twice. If you do send a wrong amount, keep
the `x-payment-deposit-payment-intent` value -- that id is what support needs.

Gas is paid in ETH on Base, separately, as normal. A wallet holding USDC but no
ETH cannot transfer at all; this is the most common first-run failure and it
looks like a SendFax problem when it is not.

---

## Step 4: retry to claim

There is no "submit proof of payment" call. The retry **is** the claim.

```sh
# Byte-identical to the first request: same PDF, same `to`, no payment header.
curl -sS -o receipt.json -w '%{http_code}' \
  -F document=@invoice.pdf \
  -F to=+14155550123 \
  https://sendfax.sh/api/v1/faxes
```

The server computes a SHA-256 hash over `to`, the price, and the document bytes
(`lib/payments/deposit.ts:94-108`), finds the pending PaymentIntent it minted for
that hash, asks Stripe for its live status, and decides:

| PaymentIntent status | Response |
|---|---|
| `requires_action` (transfer not seen) | `402`, same deposit address |
| `processing` (transfer detected) | `202` -- fax queued |
| `succeeded` (captured) | `202` -- fax queued |

`processing` is the bar, not `succeeded` (`lib/payments/deposit.ts:78-83`). The
fax dispatches as soon as Stripe sees the transfer rather than after full
capture. Your `202` can therefore arrive before the transaction is deeply
confirmed. That is deliberate -- a latency win that is safe precisely because
the amount had to be exact.

On success:

```json
{
  "id": "133718a5-6117-4652-9b49-af1d5aa2102d",
  "pageCount": 1,
  "amountUsdMicros": 50000,
  "statusUrl": "https://sendfax.sh/f/133718a5-6117-4652-9b49-af1d5aa2102d"
}
```

### Retry timing

Poll every 3-5 seconds. Two windows constrain you:

- **The deposit reuse window is ~15 minutes** (`DEPOSIT_TTL_MS`,
  `lib/payments/deposit.ts:50`). Within it, an identical request reuses the same
  PaymentIntent and the same address, so retrying is free and is *not* a second
  charge. Past it, the next attempt mints a **new** address, and your earlier
  transfer sits against a PaymentIntent nothing is claiming.
- **A USDC transfer on Base confirms in seconds**, and Stripe reports it shortly
  after. In practice you are waiting well under a minute.

So: start retrying the moment you broadcast, and do not stop until you get a
`202` or the window closes. If your process might die in between, persist the
document, the destination, and `x-payment-deposit-payment-intent` first.

### Retrying after 202 is safe

The fax row stores the PaymentIntent id, and a retry that finds an existing row
for that id returns the original receipt instead of sending a second fax
(`web/app/api/v1/faxes/route.ts:93-111`). A crashed script can re-run without
double-faxing.

---

## Step 5: poll to delivery

`GET https://sendfax.sh/api/v1/faxes/{id}` -- free, unauthenticated, no payment.
The id is an unguessable capability.

```json
{
  "id": "133718a5-6117-4652-9b49-af1d5aa2102d",
  "status": "delivered",
  "pageCount": 1,
  "toMasked": "**** 0123",
  "failureReason": null,
  "createdAt": 1783659204127,
  "terminalAt": 1783659216514
}
```

Lifecycle: `queued -> media_processed -> sending -> delivered`. Terminal states
are `delivered`, `failed`, and `expired` (`sdk/src/types.ts:16-31`). A `failed`
fax carries a human-readable `failureReason`.

The same id is a shareable status page at `https://sendfax.sh/f/{id}`.

---

## Refunds

The rule the code is built around: **never keep money for a fax we cannot
deliver** (`lib/payments/refund.ts:1-30`).

- Unsupported destinations are rejected with `422` **before** the payment gate
  runs, so the common case never involves money at all.
- A `+1` number can still be refused by the carrier -- `+1` is the whole NANP,
  so a `+1876` Jamaican number passes the prefix check and fails at Telnyx
  (`lib/domain/fax.ts:87-97`). When that happens on a Stripe-settled payment,
  which includes every deposit-mode payment, `refunds.create({ payment_intent })`
  returns the USDC **to your sending wallet** automatically.
- Refunds are best-effort on the refund itself: a Stripe error is logged and
  never allowed to block the status transition. A delayed refund is recoverable;
  a stuck fax row is worse.
- A raw on-chain MPP payment -- one that settled without a Stripe charge -- has
  nothing to reverse and is **not** auto-refunded. Email support@sendfax.sh with
  the fax id.

Since deposit mode *is* a Stripe payment, paying with USDC this way keeps the
automatic-refund guarantee. That is one of the reasons it is the wired path.

---

## Complete curl walkthrough

Every step, nothing hidden. Needs `curl` and `jq`.

```sh
API=https://sendfax.sh
PDF=invoice.pdf
TO=+14155550123

# --- 1. Submit (unpaid). Capture headers and body separately. -----------------
curl -sS -D headers.txt -o body.json -w 'HTTP %{http_code}\n' \
  -F "document=@$PDF" -F "to=$TO" "$API/api/v1/faxes"
# HTTP 402

# --- 2. Read the deposit quote off the headers. -------------------------------
grep -i '^x-payment-deposit' headers.txt
# 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...

ADDR=$(awk 'tolower($1)=="x-payment-deposit-address:"{print $2}' headers.txt | tr -d '\r')
MICROS=$(awk 'tolower($1)=="x-payment-deposit-amount-micros:"{print $2}' headers.txt | tr -d '\r')

# Optional: decode the MPP challenge to see the same charge in the other dialect.
grep -i '^www-authenticate' headers.txt \
  | sed -E 's/.*request="([^"]*)".*/\1/' | tr '_-' '/+' | base64 -d | jq
# { "amount": "50000", "currency": "0x8335...", "recipient": "0x...",
#   "methodDetails": { "chainId": 8453, "decimals": 6 } }

# --- 3. Send exactly $MICROS base units of USDC on Base to $ADDR. -------------
echo "send $MICROS USDC base units to $ADDR on Base"
# ...from any wallet. Nothing here is SendFax-specific: it is an ERC-20 transfer.

# --- 4. Retry the identical request until 202. --------------------------------
until curl -sS -o body.json -w '%{http_code}' \
        -F "document=@$PDF" -F "to=$TO" "$API/api/v1/faxes" | grep -q 202
do sleep 5; done

ID=$(jq -r .id body.json)
echo "accepted: $API/f/$ID"

# --- 5. Poll to a terminal status. --------------------------------------------
while :; do
  S=$(curl -sS "$API/api/v1/faxes/$ID" | jq -r .status)
  echo "$S"
  case $S in delivered|failed|expired) break;; esac
  sleep 2
done
```

For a packaged version of the same thing with a QR code and exit codes, see
[curl.md](/guides/curl.md).

---

## Other stablecoins and other chains

Today: **USDC on Base only.** The deposit network is `base` whenever deposit
mode is on (`lib/runtime/index.ts:113-118`), and the USDC contract is the one
above. The gate's own network switch also knows `base-sepolia` for development
(`lib/payments/gate.ts:169-181`), but that is a test network, not a payment
option.

There is no USDT path, no other L2, and no native-ETH path. If the 402 you
receive advertises a different `x-payment-deposit-network` or
`x-payment-deposit-token` than you expect, **treat that as a reason to stop, not
a reason to adapt** -- check the values against what you expect before signing
anything:

```ts
if (network !== "base") throw new Error(`unexpected network ${network}`)
if (token.toLowerCase() !== USDC_BASE.toLowerCase()) throw new Error("unexpected token")
if (amountBaseUnits !== BigInt(expectedPages) * 50_000n) throw new Error("unexpected amount")
if (amountBaseUnits > MY_CEILING) throw new Error("over my spend limit")
```

Enforce spend limits in your own code. Do not rely on prompt text, and do not
rely on the server being honest -- it is a remote party, even when it is us.

---

## Pricing

$0.05 per page, always, on this rail. `pages x 50000` base units of USDC
(`lib/domain/pricing.ts:15, 19`). No minimum, no top-up, no subscription, no
rounding: a one-page fax costs 50000 base units and a seventeen-page fax costs
850000.

The Stripe card rail charges `max($0.50, $0.05 x pages)`
(`lib/domain/pricing.ts:16, 23`) because Stripe will not process a charge below
50 cents. That floor is Stripe's, not ours, and it is the single practical
reason to pay with USDC for short documents: a one-page fax is $0.05 by wallet
and $0.50 by card.

---

## Cross-references

- [README.md](/guides/index.md) -- the chooser and the full decision matrix
- [mpp.md](/guides/mpp.md) -- the MPP dialect, decoded field by field
- [curl.md](/guides/curl.md) -- a 20-line bash implementation of this page
- [nextjs.md](/guides/nextjs.md) -- the same flow from a browser wallet
- [macos.md](/guides/macos.md) / [ios.md](/guides/ios.md) / [react-native.md](/guides/react-native.md) -- native and mobile
- <https://sendfax.sh/quickstart.md> | <https://sendfax.sh/openapi.json>
