Sending a fax via HTTP
Sending a fax via HTTP
The wire-format reference. Every other guide in this set is this protocol dressed in a language; this page is the protocol itself, with nothing between you and the bytes.
Read this if you are writing a client in a language nobody has written a guide for, debugging a request that is not behaving, or you just want the exact shape of every request and response in one place.
- Contract: https://sendfax.sh/openapi.json | docs: https://sendfax.sh/docs
- Settlement mechanics: usdc.md | the MPP dialect: mpp.md
- A runnable version of this page: curl.md
The whole protocol
POST /api/v1/faxes multipart: document (PDF) + to (E.164)
|
+-- 400 / 413 / 422 rejected BEFORE payment; nothing charged
|
+-- 402 payment required; challenge in the headers
| |
| +-- pay out of band (USDC transfer, or an MPP/x402 credential)
| |
| +-- POST /api/v1/faxes the SAME request again
| +-- 402 not settled yet; retry in a few seconds
| +-- 202 { id, pageCount, amountUsdMicros, statusUrl }
|
GET /api/v1/faxes/{id} free, unauthenticated, poll to terminal
+-- 200 { id, status, pageCount, toMasked, ... }
+-- 404
Human rail (no wallet anywhere):
POST /api/v1/uploads -> { id, pageCount, amountCents }
POST /api/v1/checkout {id} -> { url } hosted Stripe Checkout
GET /api/v1/faxes/{id} same status endpointFour endpoints. No accounts, no API keys, no bearer tokens, no session. Payment is the credential.
Which payment path am I in?
If your client can move USDC on Base, use the agent rail: POST /api/v1/faxes, pay the 402, retry. If it cannot, use the human rail: upload, create a Checkout session, and hand the URL to a person. The full decision matrix is in README.md.
Conventions
| Base URL | https://sendfax.sh |
| Transport | HTTPS only |
| Success bodies | application/json |
| Error bodies | application/problem+json (RFC 9457) |
| Character encoding | UTF-8 |
| Money | integer micro-dollars (amountUsdMicros) on the agent rail; integer cents (amountCents) on the Stripe rail |
| Time | epoch milliseconds, integer |
| Ids | opaque strings. Treat them as capabilities: unguessable, and sufficient on their own to read a fax's status |
Nothing is cacheable. Status changes second to second; the 402 carries a per-request payment address. Send Cache-Control: no-store if your stack adds caching by default.
POST /api/v1/faxes
The agent endpoint. Submits the document and, on the retry, claims the fax.
Request
Content-Type: multipart/form-data with exactly two parts:
| Part | Type | Required | Notes |
|---|---|---|---|
document | file | yes | Unencrypted PDF, <= 20 MB. Must be sent as a file part -- with a filename parameter in its Content-Disposition. |
to | text | yes | Destination fax number, E.164: ^\+[1-9]\d{7,14}$ |
On the wire:
POST /api/v1/faxes HTTP/1.1
Host: sendfax.sh
Content-Type: multipart/form-data; boundary=----sendfax9f3c2a
Content-Length: 24601
------sendfax9f3c2a
Content-Disposition: form-data; name="document"; filename="invoice.pdf"
Content-Type: application/pdf
%PDF-1.7
...binary...
------sendfax9f3c2a
Content-Disposition: form-data; name="to"
+14155550123
------sendfax9f3c2a--Two details that break clients:
The document part must have a filename. The server checks that the part parsed as a file, not as a text field (web/app/api/v1/faxes/route.ts:56-58). A part with no filename parameter parses as a plain string and you get 400 Missing document. Some form-data libraries omit the filename when you hand them raw bytes -- React Native's FormData is one; see react-native.md.
The boundary does not matter, but the bytes do. The retry is matched by a hash of to, the price, and the document bytes (lib/payments/deposit.ts:94-108) -- not by the boundary, the filename, or the part order. You may regenerate the multipart envelope freely; you may not re-encode, re-compress, or otherwise touch the PDF between attempts.
Response: 402 Payment Required
The first call is always unpaid, so this is always what you get first.
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.",
"hint": "Use a supported wallet to pay for this resource using one of the supported payment methods returned in the WWW-Authenticate header. See https://mpp.dev/tools/wallet.md",
"challengeId": "cibtb1AF_IjPZk5Dk_pXiSxhf-pEYBrpGjFrPemcsO8"
}The same charge is described three times. Act on whichever your client can.
Dialect 1: MPP, WWW-Authenticate: Payment
An RFC 7235 challenge, scheme Payment. Auth-params: id, realm, method (evm), intent (charge), expires (ISO 8601), and request -- base64url JSON:
{
"amount": "50000",
"currency": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
"recipient": "0x<the service's static receiving address>",
"methodDetails": { "chainId": 8453, "credentialTypes": ["authorization"], "decimals": 6 }
}amount is a string in the token's base units, deliberately: it can exceed Number.MAX_SAFE_INTEGER in other currencies and must never round-trip through a float. Pay it by retrying with a PAYMENT header. Full field-by-field breakdown in mpp.md.
Parsing note. The request value is base64url and never contains a comma; other params are quoted strings that can. Splitting the header on commas is the obvious approach and it is wrong. Scan for key=value pairs, accepting both quoted-string and token68 forms:
([A-Za-z0-9._-]+)\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^,\s]+))Dialect 2: x402, PAYMENT-REQUIRED
Base64 of a payment-requirements body. Decoded:
{
"x402Version": 1,
"error": "payment required",
"accepts": [
{
"scheme": "exact",
"network": "base",
"asset": "USDC",
"maxAmountRequired": "50000",
"resource": "https://sendfax.sh/api/v1/faxes",
"description": "Fax transmission -- $0.05/page",
"mimeType": "application/json"
}
]
}maxAmountRequired and the MPP amount are the same number in the same units. Pay it by retrying with an X-PAYMENT header.
Dialect 3: the deposit headers -- the one that settles
| Header | Example | Meaning |
|---|---|---|
x-payment-deposit-mode | stripe-deposit | Which settlement model is in force |
x-payment-deposit-network | base | Chain to send on |
x-payment-deposit-address | 0x157a...52bb | Send here. Minted fresh for this request |
x-payment-deposit-token | 0x8335...2913 | USDC ERC-20 contract on that network |
x-payment-deposit-amount | 0.050000 | Human-readable amount |
x-payment-deposit-amount-micros | 50000 | Exact base units. Use this one |
x-payment-deposit-payment-intent | pi_3Q... | Stripe PaymentIntent id; keep it for support |
x-payment-deposit-instructions | a URL | Pointer to the long-form explanation |
Send exactly x-payment-deposit-amount-micros base units of the token to x-payment-deposit-address as a plain ERC-20 transfer. No signature, no approval, no facilitator. Then retry.
recipientin the MPP challenge is not the deposit address. It is the service's static receiving wallet (lib/runtime/index.ts:185). In deposit mode -- the wired configuration -- funds sent there settle nothing and cannot be matched to your request. Always payx-payment-deposit-address.
Detect the mode from one header:
x-payment-deposit-mode: stripe-deposit -> pay by transfer; a credential will not settle
(absent) -> the mppx charge is live; pay with PAYMENT / X-PAYMENTResponse: 202 Accepted
Payment recognised; the fax is queued.
HTTP/1.1 202 Accepted
Content-Type: application/json
Payment-Receipt: settled;rail=x402;mode=stripe-deposit;pi=pi_3Q...
{
"id": "133718a5-6117-4652-9b49-af1d5aa2102d",
"pageCount": 1,
"amountUsdMicros": 50000,
"statusUrl": "https://sendfax.sh/f/133718a5-6117-4652-9b49-af1d5aa2102d"
}| Field | Type | Notes |
|---|---|---|
id | string | The fax id. Poll key and status-page slug |
pageCount | integer | Pages billed |
amountUsdMicros | integer | What you paid, in micro-dollars |
statusUrl | string (URI) | Shareable human status page |
A Payment-Receipt header rides along, echoing the rail that settled. On the x402 dialect a PAYMENT-RESPONSE settlement header appears too (lib/payments/gate.ts:332-337). The published OpenAPI describes FaxAccepted with only id and statusUrl; the route returns all four fields above, and the SDK's SendFaxResult type matches the route.
Idempotency and retry semantics
This is the part most worth getting right, so it is spelled out completely.
There is no "submit proof of payment" call. The retry is the claim. You re-send the identical request and the server decides.
What "identical" means. The server hashes SHA-256 over to, the amount in micro-dollars, and the document bytes (lib/payments/deposit.ts:94-108). Same three inputs, same hash, same pending PaymentIntent. Change any of them -- a different PDF, a different destination, a different page count -- and you get a different hash and a freshly minted deposit address, leaving your earlier transfer unclaimed.
The reuse window is ~15 minutes. DEPOSIT_TTL_MS is 15 minutes (lib/payments/deposit.ts:50). Within it, identical retries reuse the same PaymentIntent and the same address, so:
- Retrying is free and is not a second charge.
- Retrying as fast as you like is safe; every 3-5 seconds is a good cadence.
- Going quiet for longer than the window and then retrying mints a new address, and your earlier transfer sits against a PaymentIntent nothing is claiming.
So: begin retrying the moment you broadcast the transfer, and do not stop until you get a 202 or the window closes. Persist x-payment-deposit-payment-intent first if your process might die.
What the server checks on each retry. It reads the live Stripe PaymentIntent status (lib/payments/gate.ts:299-324):
| PaymentIntent status | Response |
|---|---|
requires_action -- transfer not seen | 402, same address |
processing -- transfer detected | 202 |
succeeded -- captured | 202 |
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 capture, so your 202 can precede deep confirmation. That is deliberate, and it is safe precisely because the amount had to be exact.
Retrying after 202 is idempotent. The fax row stores the PaymentIntent id; a retry that finds an existing row for it returns the original receipt instead of sending a second fax (web/app/api/v1/faxes/route.ts:93-111). A client that crashes between receiving the 202 and recording it can safely re-run.
Backoff. Nothing here is rate-limited per se, but be a good citizen: 3 seconds, growing 1.5x to a 15-second cap, is what every guide in this set uses.
GET /api/v1/faxes/{id}
Free, unauthenticated, no payment. Shared by agents and the human status page.
GET /api/v1/faxes/133718a5-6117-4652-9b49-af1d5aa2102d HTTP/1.1
Host: sendfax.shHTTP/1.1 200 OK
Content-Type: application/json
{
"id": "133718a5-6117-4652-9b49-af1d5aa2102d",
"status": "delivered",
"pageCount": 1,
"toMasked": "**** 0123",
"failureReason": null,
"createdAt": 1783659204127,
"terminalAt": 1783659216514
}| Field | Type | Notes | |
|---|---|---|---|
id | string | Echoes the request | |
status | enum | See the lifecycle below | |
pageCount | integer | Pages billed | |
toMasked | string | Destination redacted to its last 4 digits | |
failureReason | string \ | null | Set only when status is failed |
createdAt | integer | Epoch ms | |
terminalAt | integer \ | null | Epoch ms at terminal state; null until then |
The raw destination number is never returned, and is nulled entirely once the fax reaches a terminal state.
Status lifecycle
awaiting_payment --> paid --> queued --> media_processed --> sending --> delivered
(human rail) \
+-> failed
(agent rail enters at `paid`: the row is inserted already settled) +-> expired| Status | Meaning |
|---|---|
awaiting_payment | Human-rail upload, not yet paid |
paid | Payment settled, not yet handed to the carrier |
queued | Accepted by the carrier |
media_processed | Document converted for transmission |
sending | Dialing / transmitting |
delivered | Terminal. Sent successfully |
failed | Terminal. See failureReason (busy, no answer, not a fax line, destination refused) |
expired | Terminal. An unpaid human upload past its payment window |
Terminal statuses are delivered, failed, expired (sdk/src/types.ts:27). A fax in one of them will never change again -- stop polling.
Poll every 1.5-2 seconds. There is no push, no webhook for callers, and no long-poll.
The human rail
Two endpoints backing the no-wallet flow. Not part of the agent flow, and priced differently.
POST /api/v1/uploads
Same multipart body as POST /api/v1/faxes. Validates, counts pages, prices, and stores nothing. Charges nothing.
HTTP/1.1 200 OK
Content-Type: application/json
{ "id": "133718a5-...", "pageCount": 3, "amountCents": 50 }amountCents is max(50, ceil(pages * 5)) (lib/domain/pricing.ts:23) -- the floor is Stripe's minimum charge, not our margin.
POST /api/v1/checkout
POST /api/v1/checkout HTTP/1.1
Content-Type: application/json
{ "id": "133718a5-..." }HTTP/1.1 200 OK
Content-Type: application/json
{ "url": "https://checkout.stripe.com/c/pay/cs_live_..." }Open that URL in a browser. Two things to plan for:
409 Conflictif the fax is not inawaiting_payment(web/app/api/v1/checkout/route.ts:35-37) -- you already started or completed checkout for it.- The success redirect goes to us, not you.
success_urlishttps://sendfax.sh/f/{id}?paid=1(lib/payments/stripe.ts:139) and is not configurable through the API. Your client learns that payment landed by pollingGET /api/v1/faxes/{id}, never by receiving a callback.
Errors
Every non-2xx except the 402 is application/problem+json (RFC 9457):
{
"type": "about:blank",
"title": "Unsupported destination",
"status": 422,
"detail": "SendFax currently delivers to the United States, Canada, Mexico, and Saint Pierre & Miquelon."
}| Status | title | When | Charged? |
|---|---|---|---|
400 | Bad request | Body was not parseable multipart | no |
400 | Missing document | document absent, or not a file part | no |
402 | Payment required | Unpaid, or a payment fault | no |
404 | Fax not found | Unknown id | n/a |
409 | Conflict | Fax is not in a state that allows the action | n/a |
413 | Document too large | PDF over 20 MB | no |
422 | Invalid destination | to is not E.164 | no |
422 | Unsupported destination | Valid E.164, outside US/CA/MX/PM | no |
422 | Invalid PDF | Not a PDF, encrypted, or zero pages | no |
502 | Fax provider error | The carrier rejected the request; retry | no |
500 | Internal error | Unmodelled fault. Never leaks internals | no |
The full mapping is one function: lib/runtime/index.ts:259-283.
Two 422s share a status code, so route on title, not on the number: a title matching /destination/i is a destination problem, anything else at 422 is a document problem. That is exactly what the SDK does (sdk/src/errors.ts:117-120).
Validation happens before payment
The order in web/app/api/v1/faxes/route.ts is deliberate and worth relying on:
- Parse the multipart body (line 45)
- Check
documentis a file (56) - Check
tois E.164 (59) - Check the destination is supported (62)
- Check the size (65)
- Count pages, compute the price (70-71)
- Then run the payment gate (81)
So a bad request costs nothing and mints no deposit address. You never have to unwind a payment because of a validation error.
Destinations
+1 (the whole NANP), +52 (Mexico), +508 (Saint Pierre & Miquelon) -- lib/domain/fax.ts:70-78. Anything else is 422 up front.
+1 is the whole NANP, which includes about twenty Caribbean and Pacific territories. A +1876 Jamaican number passes validation and is then refused by the carrier (lib/domain/fax.ts:87-97), surfacing as status: "failed" with a destination-related failureReason. On a Stripe-settled payment -- which includes every deposit-mode payment -- that is auto-refunded to the sending wallet (lib/payments/refund.ts:1-30).
Pricing
| Rail | Formula | One page | Three pages |
|---|---|---|---|
| Agent (USDC) | pages x 50000 micro-dollars | $0.05 | $0.15 |
| Human (Stripe) | max(50, ceil(pages x 5)) cents | $0.50 | $0.50 |
lib/domain/pricing.ts:15-24. No minimum on the agent rail, no top-up, no subscription. The card floor is Stripe's minimum charge.
Development
Run the server in FAX_MODE=mock and the payment step becomes one header (lib/payments/gate.ts:284-293):
POST /api/v1/faxes HTTP/1.1
Host: localhost:3001
x-mock-payment: 1
Content-Type: multipart/form-data; boundary=...The 402 you get without that header is genuine -- issued by the real challenge machinery -- so you can build a complete client against the true wire shape with no wallet. In mock mode the status machine advances on read, one step per GET about 3 seconds apart (web/app/api/v1/faxes/[id]/route.ts:24-32), so a poll loop reaches delivered in roughly 15 seconds. The header does nothing in production.
Conformance fixtures captured from the live API live in sdk/test/fixtures/*.json: a real 402 with its decoded challenge, a 202 receipt, a delivered FaxPublic, and two problem bodies. They are the cheapest way to test a parser in a new language.
Discovery surfaces
| URL | What |
|---|---|
/openapi.json | OpenAPI 3.1 -- every endpoint, the 402, the schemas |
/quickstart.md | The four-step version |
/llms.txt | Index of every machine-readable surface |
/llms-full.txt | Every doc inlined into one file |
/auth.md | The no-auth statement |
/SKILL.md | A portable agent skill |
/.well-known/{agent-card.json, mcp/server-card.json, ai-plugin.json, api-catalog} | Agent manifests |
Every HTML page has a markdown twin: append .md, or send Accept: text/markdown. RFC 8288 Link headers cross-reference the two.
Cross-references
- curl.md -- this page, runnable
- usdc.md -- the on-chain half in depth
- mpp.md -- the MPP dialect field by field
- typescript.md | nextjs.md | cloudflare-workers.md
- macos.md | ios.md | react-native.md
- https://sendfax.sh/openapi.json | https://sendfax.sh/docs