Sending a fax with USDC and stablecoins

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.


The flow

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

FieldTypeNotes
documentfileUnencrypted PDF, <= 20 MB
tostringDestination 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:

StatusMeaningWhere
400document missing or not a file partroute.ts:56-58
422to is not E.164route.ts:59-61
422to is E.164 but outside US/CA/MX/PMroute.ts:62-64
413PDF over 20 MBroute.ts:65-67
422PDF unreadable, encrypted, or zero pageslib/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.

text
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`):

HeaderExampleUse
x-payment-deposit-modestripe-depositconfirms which settlement model
x-payment-deposit-networkbasethe chain to send on
x-payment-deposit-address0x157a...52bbsend here -- fresh per request
x-payment-deposit-token0x8335...2913USDC ERC-20 contract on Base
x-payment-deposit-amount0.050000human-readable amount
x-payment-deposit-amount-micros50000exact base units -- use this
x-payment-deposit-payment-intentpi_3Q...Stripe PaymentIntent id; keep it
x-payment-deposit-instructionsa URLpointer 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

NetworkBase mainnet, chain id 8453
TokenUSDC, 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 (lib/payments/deposit.ts:53-55)
Decimals6 -- so 50000 base units = $0.05
CallERC-20 transfer(address,uint256), selector 0xa9059cbb
to (tx)the USDC contract
to (arg)x-payment-deposit-address
amount (arg)x-payment-deposit-amount-micros
Approvalsnone. 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:

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

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


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