# Sending a fax with curl

No SDK, no dependencies, no language runtime. `curl` and `jq` are enough to send
a fax end to end, and the result is short enough to read in one sitting -- which
makes this the best page to start from even if you plan to use a library later.

Optional: `qrencode`, so a human can pay from a phone wallet by scanning.

- Wire format reference: [http.md](/guides/http.md) | settlement: [usdc.md](/guides/usdc.md)
- Docs: <https://sendfax.sh/docs> | contract: <https://sendfax.sh/openapi.json>

---

## The two flows

```
WALLET PATH                              CARD PATH (no wallet anywhere)
  POST /api/v1/faxes                       POST /api/v1/uploads
    +-> 402 + x-payment-deposit-*            +-> { id, pageCount, amountCents }
  print address + amount + QR              POST /api/v1/checkout { id }
    [pay from any wallet]                    +-> { url }
  POST /api/v1/faxes  (identical)          print/open url  [pay in a browser]
    +-> 402 ... retry ... -> 202                     |
              |                                      |
              +----------------+---------------------+
                               |
                    GET /api/v1/faxes/{id}
                    poll to delivered | failed | expired
```

## Which payment path am I in?

**Can you move USDC on Base?** A funded wallet -- yours, or a person's phone --
means the wallet path: $0.05 per page, no minimum. No wallet means the card path:
`max($0.50, $0.05 x pages)`, because Stripe will not charge under 50 cents. Both
paths converge on the same free status endpoint.

---

## The 20-line reference implementation

```bash
#!/usr/bin/env bash
# sendfax -- send a fax, pay the 402 by USDC deposit. Needs: curl, jq. Optional: qrencode.
set -euo pipefail
pdf=$1 to=$2; api=${SENDFAX_API:-https://sendfax.sh}; h=$(mktemp) b=$(mktemp)

claim() { curl -sS -D "$h" -o "$b" -w '%{http_code}' -F "document=@$pdf" -F "to=$to" "$api/api/v1/faxes"; }
hdr() { awk -v k="$1" 'tolower($1)==k":" {sub(/^[^:]*: */,""); print; exit}' "$h" | tr -d '\r'; }

[ "$(claim)" = 402 ] || { jq -r '.detail // .title // .' "$b" >&2; exit 1; }   # 422/413 land here
addr=$(hdr x-payment-deposit-address); amt=$(hdr x-payment-deposit-amount)
net=$(hdr x-payment-deposit-network);  tok=$(hdr x-payment-deposit-token)
micros=$(hdr x-payment-deposit-amount-micros)

echo "Send EXACTLY $amt USDC on $net to $addr"     # exact amount; nothing else matches
command -v qrencode >/dev/null && qrencode -t ANSIUTF8 "ethereum:$tok@8453/transfer?address=$addr&uint256=$micros"

until [ "$(claim)" = 202 ]; do sleep 5; done       # the retry IS the claim; same address ~15 min
id=$(jq -r .id "$b"); echo "accepted: $api/f/$id"

while :; do s=$(curl -sS "$api/api/v1/faxes/$id" | jq -r .status); echo "$s"
  case $s in delivered) exit 0;; failed|expired) exit 6;; esac; sleep 2; done
```

```sh
chmod +x sendfax && ./sendfax invoice.pdf +14155550123
```

That is the entire agent protocol. Everything below explains a piece of it.

---

## Step 1: submit, and read the 402

```sh
curl -sS -D headers.txt -o body.json -w 'HTTP %{http_code}\n' \
  -F document=@invoice.pdf \
  -F to=+14155550123 \
  https://sendfax.sh/api/v1/faxes
# HTTP 402
```

`-F document=@file` is doing real work: it sends a *file* part with a `filename`
parameter, which the server requires -- a part without one parses as a text field
and returns `400 Missing document` (`web/app/api/v1/faxes/route.ts:56-58`).

The first call is always `402`. Everything you need is in the headers:

```sh
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...
# x-payment-deposit-instructions: https://sendfax.sh/docs#stripe-deposit
```

Extracting one, case-insensitively (header names are case-insensitive and `curl
-D` writes them exactly as the server sent them):

```sh
hdr() { awk -v k="$1" 'tolower($1)==k":" {sub(/^[^:]*: */,""); print; exit}' headers.txt | tr -d '\r'; }

ADDR=$(hdr x-payment-deposit-address)
MICROS=$(hdr x-payment-deposit-amount-micros)
PI=$(hdr x-payment-deposit-payment-intent)
```

Use `x-payment-deposit-amount-micros` (`50000`), not
`x-payment-deposit-amount` (`0.050000`). Deposit mode matches on the exact
base-unit integer (`lib/payments/deposit.ts:16-18`), and pushing a decimal
through `bc` or `awk` is how you end up one unit short of a match that has to be
exact.

**Log `$PI` before you pay.** If anything goes wrong afterwards, that
PaymentIntent id is what support needs.

### Reading the other dialects

The same charge is also advertised as an MPP challenge and an x402 requirements
body. You do not need either to pay, but they are useful when debugging:

```sh
# MPP: decode the base64url `request` auth-param.
grep -i '^www-authenticate' headers.txt \
  | sed -E 's/.*request="([^"]*)".*/\1/' \
  | tr '_-' '/+' | base64 -d 2>/dev/null | jq
# { "amount": "50000", "currency": "0x8335...", "recipient": "0x...",
#   "methodDetails": { "chainId": 8453, "decimals": 6 } }

# x402: decode the PAYMENT-REQUIRED header.
hdr payment-required | base64 -d 2>/dev/null | jq '.accepts[0]'
```

> **Do not pay `recipient`.** That address in the MPP challenge is the service's
> static receiving wallet (`lib/runtime/index.ts:185`), not your per-request
> deposit address. Funds sent there settle nothing. Pay
> `x-payment-deposit-address`.

### What the error responses look like

Validation runs before the payment gate, so a rejected request costs nothing:

```sh
curl -sS -o - -w '\nHTTP %{http_code}\n' \
  -F document=@invoice.pdf -F to=+442071234567 \
  https://sendfax.sh/api/v1/faxes
# {"type":"about:blank","title":"Unsupported destination","status":422,
#  "detail":"SendFax currently delivers to the United States, Canada, Mexico, and Saint Pierre & Miquelon."}
# HTTP 422
```

Route on `title`, not on the status code -- `422` covers both a bad destination
and a bad PDF:

```sh
jq -r 'if .title | test("destination"; "i") then "destination" else "document" end' body.json
```

---

## Step 2: pay

Send exactly `$MICROS` base units of USDC on Base to `$ADDR`, as a plain ERC-20
`transfer`. No approval, no signature, no facilitator. Any wallet can do it --
this step is not SendFax-specific in any way.

For a human with a phone, print a scannable code:

```sh
qrencode -t ANSIUTF8 "ethereum:$TOK@8453/transfer?address=$ADDR&uint256=$MICROS"
```

That is EIP-681 in its token-transfer form: token contract, `@8453` for Base
mainnet, then the recipient and amount. Wallet support for this form is uneven --
some scan it perfectly, some open a blank send screen, some ignore it. **Always
print the address and amount as text as well**, which the reference script does.

Two failure modes worth pre-empting:

- **A wallet with USDC but no ETH cannot transfer at all.** Gas on Base is a
  fraction of a cent, but it is not zero. This is the most common first-run
  problem and it looks like a SendFax fault.
- **Rounding up "to be safe" breaks the match.** Send the exact integer.

---

## Step 3: retry to claim

```sh
until curl -sS -o body.json -w '%{http_code}' \
        -F document=@invoice.pdf -F to=+14155550123 \
        https://sendfax.sh/api/v1/faxes | grep -q 202
do sleep 5; done

jq -r '"\(.id)  \(.pageCount) page(s)  \(.amountUsdMicros/1000000) USD  \(.statusUrl)"' body.json
```

There is no "submit proof" call: **the retry is the claim.** The server hashes
`to` + the price + the document bytes, finds the PaymentIntent it minted for that
hash, asks Stripe for its status, and answers `202` as soon as the transfer is
seen (`lib/payments/deposit.ts:94-108`, `lib/payments/gate.ts:299-324`).

Three properties of that loop, all load-bearing:

- **`curl -F` re-reads the file each time**, so the bytes are identical by
  construction. Do not rewrite, re-compress, or `sed` the PDF between attempts.
- **Retrying is not a second charge.** Identical requests reuse the same
  PaymentIntent for ~15 minutes (`lib/payments/deposit.ts:50`).
- **Do not stop and come back later.** Past the 15-minute window a retry mints a
  *new* address and your transfer is stranded against a PaymentIntent nothing is
  claiming. Start the loop the moment you broadcast.

`processing`, not `succeeded`, is the settlement bar
(`lib/payments/deposit.ts:78-83`) -- the fax dispatches as soon as Stripe sees
the transfer, so `202` can arrive before deep confirmation.

Retrying **after** a `202` is safe: the fax row stores the PaymentIntent id, so a
duplicate returns the original receipt rather than sending a second fax
(`web/app/api/v1/faxes/route.ts:93-111`). A `set -e` script that dies between the
202 and the poll can simply be re-run.

---

## Step 4: poll to delivery

```sh
ID=$(jq -r .id body.json)

while :; do
  S=$(curl -sS "https://sendfax.sh/api/v1/faxes/$ID" | jq -r .status)
  echo "$S"
  case $S in delivered|failed|expired) break;; esac
  sleep 2
done
```

Free and unauthenticated -- the id is an unguessable capability.

```sh
curl -sS "https://sendfax.sh/api/v1/faxes/$ID" | jq
```

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

Useful one-liners:

```sh
# One-line status with elapsed time.
curl -sS "https://sendfax.sh/api/v1/faxes/$ID" \
| jq -r 'if .terminalAt then "\(.status) in \((.terminalAt - .createdAt)/1000)s" else .status end'

# Fail loudly on a failed fax, printing the carrier's reason.
curl -sS "https://sendfax.sh/api/v1/faxes/$ID" \
| jq -e 'if .status == "failed" then error(.failureReason) else . end' >/dev/null
```

The same id is a shareable live page at `https://sendfax.sh/f/$ID`.

---

## The card path: no wallet anywhere

Same script, different middle. A person pays with a card, Stripe Link, or
Stripe's own crypto option.

```bash
#!/usr/bin/env bash
# sendfax-human -- upload, create a Stripe Checkout session, print the URL, poll.
set -euo pipefail
pdf=$1 to=$2; api=${SENDFAX_API:-https://sendfax.sh}

up=$(curl -sS -F "document=@$pdf" -F "to=$to" "$api/api/v1/uploads")
id=$(jq -r .id <<<"$up")
[ "$id" != null ] || { jq -r '.detail // .title' <<<"$up" >&2; exit 3; }

pages=$(jq -r .pageCount <<<"$up")
cents=$(jq -r .amountCents <<<"$up")
printf 'Fax %s: %s page(s), $%d.%02d\n' "$id" "$pages" "$((cents / 100))" "$((cents % 100))"

url=$(curl -sS -H 'content-type: application/json' -d "{\"id\":\"$id\"}" \
        "$api/api/v1/checkout" | jq -r .url)
echo "Pay here: $url"
command -v qrencode >/dev/null && qrencode -t ANSIUTF8 "$url"   # scan from a phone
command -v open >/dev/null && open "$url"                        # macOS convenience

while :; do s=$(curl -sS "$api/api/v1/faxes/$id" | jq -r .status); echo "$s"
  case $s in delivered) exit 0;; failed|expired) exit 6;; esac; sleep 2; done
```

Three things to know:

- **The price is different.** `amountCents` is `max(50, ceil(pages x 5))`
  (`lib/domain/pricing.ts:23`) -- Stripe will not process a charge under 50
  cents. A one-page fax is $0.05 by wallet and $0.50 by card.
- **You will not be told when payment lands.** Checkout redirects to
  `https://sendfax.sh/f/{id}?paid=1` (`lib/payments/stripe.ts:139`), not to
  anything of yours. The poll loop is how the script finds out.
- **`409 Conflict`** from `/api/v1/checkout` means the fax is not in
  `awaiting_payment` (`web/app/api/v1/checkout/route.ts:35-37`) -- you already
  created a session for it.

The QR of the Checkout URL is a genuinely nice touch for a terminal-bound
operator with a phone in hand.

---

## Exit codes

The reference script uses these; they are a reasonable convention to copy.

| Code | Meaning |
|---|---|
| 0 | delivered |
| 1 | unexpected error |
| 2 | usage |
| 3 | invalid or unsupported destination (422, before any payment) |
| 4 | invalid document (not a PDF, encrypted, zero pages, over 20 MB) |
| 5 | payment required and not settled -- no wallet, or the window expired |
| 6 | reached a terminal `failed` / `expired` state |

---

## Development

Against a dev server in `FAX_MODE=mock`, one header replaces the whole payment
step (`lib/payments/gate.ts:284-293`):

```sh
SENDFAX_API=http://localhost:3001
curl -sS -H 'x-mock-payment: 1' \
  -F document=@invoice.pdf -F to=+14155550123 \
  "$SENDFAX_API/api/v1/faxes" | jq
```

The 402 you get *without* that header is genuine, so you can develop the whole
flow against the real challenge shape. Mock mode also advances the status machine
on read -- one step per `GET`, about 3 seconds apart
(`web/app/api/v1/faxes/[id]/route.ts:24-32`) -- so the poll loop reaches
`delivered` in roughly 15 seconds. The header does nothing in production.

---

## 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, no money moves.

**Header case.** HTTP header names are case-insensitive; `curl -D` writes them as
the server sent them. Lowercase before comparing -- do not grep for an exact-case
string.

**`jq` on a 402.** The 402 body is `application/problem+json`, not the receipt.
The reference script only parses `$b` after a `202`. If you add error reporting,
read `.detail // .title` for every non-2xx.

**Per-request address, ~15-minute window.** Identical retries reuse the same
PaymentIntent for `DEPOSIT_TTL_MS` = 15 minutes
(`lib/payments/deposit.ts:50, 233-252`). For anything unattended, log
`x-payment-deposit-payment-intent` before you send the transfer.

**Undeliverable after payment is refunded.** A `+1` NANP-island number passes
validation and fails at the carrier (`lib/domain/fax.ts:87-97`); on a
Stripe-settled payment, including the deposit rail, the refund returns USDC to
the sending wallet (`lib/payments/refund.ts:1-30`). A raw on-chain MPP payment
has no Stripe charge to reverse and is not auto-refunded.

**`set -euo pipefail` and `curl`.** `curl` exits 0 for an HTTP 402 unless you
pass `-f`, which is exactly what you want here -- the 402 is a normal part of the
protocol, not an error.

---

## Cross-references

- [http.md](/guides/http.md) -- the wire format in full, language-agnostic
- [usdc.md](/guides/usdc.md) -- the settlement half in depth
- [typescript.md](/guides/typescript.md) -- the same flow with the `sendfax` SDK
- [README.md](/guides/index.md) -- the chooser and the decision matrix
- <https://sendfax.sh/quickstart.md> | <https://sendfax.sh/openapi.json>
