Sending a fax with curl
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 | settlement: 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 | expiredWhich 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
#!/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; donechmod +x sendfax && ./sendfax invoice.pdf +14155550123That is the entire agent protocol. Everything below explains a piece of it.
Step 1: submit, and read the 402
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:
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-depositExtracting one, case-insensitively (header names are case-insensitive and curl -D writes them exactly as the server sent them):
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:
# 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. Payx-payment-deposit-address.
What the error responses look like
Validation runs before the payment gate, so a rejected request costs nothing:
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 422Route on title, not on the status code -- 422 covers both a bad destination and a bad PDF:
jq -r 'if .title | test("destination"; "i") then "destination" else "document" end' body.jsonStep 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:
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
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.jsonThere 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 -Fre-reads the file each time, so the bytes are identical by construction. Do not rewrite, re-compress, orsedthe 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
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
doneFree and unauthenticated -- the id is an unguessable capability.
curl -sS "https://sendfax.sh/api/v1/faxes/$ID" | jq{
"id": "133718a5-6117-4652-9b49-af1d5aa2102d",
"status": "delivered",
"pageCount": 1,
"toMasked": "**** 0123",
"failureReason": null,
"createdAt": 1783659204127,
"terminalAt": 1783659216514
}Useful one-liners:
# 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/nullThe 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.
#!/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; doneThree things to know:
- The price is different.
amountCentsismax(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 Conflictfrom/api/v1/checkoutmeans the fax is not inawaiting_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):
SENDFAX_API=http://localhost:3001
curl -sS -H 'x-mock-payment: 1' \
-F document=@invoice.pdf -F to=+14155550123 \
"$SENDFAX_API/api/v1/faxes" | jqThe 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 -- the wire format in full, language-agnostic
- usdc.md -- the settlement half in depth
- typescript.md -- the same flow with the
sendfaxSDK - README.md -- the chooser and the decision matrix
- https://sendfax.sh/quickstart.md | https://sendfax.sh/openapi.json