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.


The whole protocol

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

Four 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 URLhttps://sendfax.sh
TransportHTTPS only
Success bodiesapplication/json
Error bodiesapplication/problem+json (RFC 9457)
Character encodingUTF-8
Moneyinteger micro-dollars (amountUsdMicros) on the agent rail; integer cents (amountCents) on the Stripe rail
Timeepoch milliseconds, integer
Idsopaque 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:

PartTypeRequiredNotes
documentfileyesUnencrypted PDF, <= 20 MB. Must be sent as a file part -- with a filename parameter in its Content-Disposition.
totextyesDestination fax number, E.164: ^\+[1-9]\d{7,14}$

On the wire:

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

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.",
  "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:

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:

text
([A-Za-z0-9._-]+)\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^,\s]+))

Dialect 2: x402, PAYMENT-REQUIRED

Base64 of a payment-requirements body. Decoded:

json
{
  "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

HeaderExampleMeaning
x-payment-deposit-modestripe-depositWhich settlement model is in force
x-payment-deposit-networkbaseChain to send on
x-payment-deposit-address0x157a...52bbSend here. Minted fresh for this request
x-payment-deposit-token0x8335...2913USDC ERC-20 contract on that network
x-payment-deposit-amount0.050000Human-readable amount
x-payment-deposit-amount-micros50000Exact base units. Use this one
x-payment-deposit-payment-intentpi_3Q...Stripe PaymentIntent id; keep it for support
x-payment-deposit-instructionsa URLPointer 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.

recipient in 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 pay x-payment-deposit-address.

Detect the mode from one header:

text
x-payment-deposit-mode: stripe-deposit   -> pay by transfer; a credential will not settle
(absent)                                 -> the mppx charge is live; pay with PAYMENT / X-PAYMENT

Response: 202 Accepted

Payment recognised; the fax is queued.

text
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"
}
FieldTypeNotes
idstringThe fax id. Poll key and status-page slug
pageCountintegerPages billed
amountUsdMicrosintegerWhat you paid, in micro-dollars
statusUrlstring (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 statusResponse
requires_action -- transfer not seen402, same address
processing -- transfer detected202
succeeded -- captured202

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.

text
GET /api/v1/faxes/133718a5-6117-4652-9b49-af1d5aa2102d HTTP/1.1
Host: sendfax.sh
text
HTTP/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
}
FieldTypeNotes
idstringEchoes the request
statusenumSee the lifecycle below
pageCountintegerPages billed
toMaskedstringDestination redacted to its last 4 digits
failureReasonstring \nullSet only when status is failed
createdAtintegerEpoch ms
terminalAtinteger \nullEpoch 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

text
  awaiting_payment --> paid --> queued --> media_processed --> sending --> delivered
       (human rail)                                                    \
                                                                        +-> failed
  (agent rail enters at `paid`: the row is inserted already settled)    +-> expired
StatusMeaning
awaiting_paymentHuman-rail upload, not yet paid
paidPayment settled, not yet handed to the carrier
queuedAccepted by the carrier
media_processedDocument converted for transmission
sendingDialing / transmitting
deliveredTerminal. Sent successfully
failedTerminal. See failureReason (busy, no answer, not a fax line, destination refused)
expiredTerminal. 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.

text
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

text
POST /api/v1/checkout HTTP/1.1
Content-Type: application/json

{ "id": "133718a5-..." }
text
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 Conflict if the fax is not in awaiting_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_url is https://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 polling GET /api/v1/faxes/{id}, never by receiving a callback.

Errors

Every non-2xx except the 402 is application/problem+json (RFC 9457):

json
{
  "type": "about:blank",
  "title": "Unsupported destination",
  "status": 422,
  "detail": "SendFax currently delivers to the United States, Canada, Mexico, and Saint Pierre & Miquelon."
}
StatustitleWhenCharged?
400Bad requestBody was not parseable multipartno
400Missing documentdocument absent, or not a file partno
402Payment requiredUnpaid, or a payment faultno
404Fax not foundUnknown idn/a
409ConflictFax is not in a state that allows the actionn/a
413Document too largePDF over 20 MBno
422Invalid destinationto is not E.164no
422Unsupported destinationValid E.164, outside US/CA/MX/PMno
422Invalid PDFNot a PDF, encrypted, or zero pagesno
502Fax provider errorThe carrier rejected the request; retryno
500Internal errorUnmodelled fault. Never leaks internalsno

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:

  1. Parse the multipart body (line 45)
  2. Check document is a file (56)
  3. Check to is E.164 (59)
  4. Check the destination is supported (62)
  5. Check the size (65)
  6. Count pages, compute the price (70-71)
  7. 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

RailFormulaOne pageThree 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):

text
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

URLWhat
/openapi.jsonOpenAPI 3.1 -- every endpoint, the 402, the schemas
/quickstart.mdThe four-step version
/llms.txtIndex of every machine-readable surface
/llms-full.txtEvery doc inlined into one file
/auth.mdThe no-auth statement
/SKILL.mdA 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