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.


The two flows

text
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.

CodeMeaning
0delivered
1unexpected error
2usage
3invalid or unsupported destination (422, before any payment)
4invalid document (not a PDF, encrypted, zero pages, over 20 MB)
5payment required and not settled -- no wallet, or the window expired
6reached 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