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,
  "failureCategory": null,
  "createdAt": 1783659204127,
  "terminalAt": 1783659216514
}
FieldTypeNotes
idstringEchoes the request
statusenumSee the lifecycle below
pageCountintegerPages billed
toMaskedstringDestination redacted to its last 4 digits
failureReasonstring \nullRaw carrier token, set only when status is failed. A support code, not a sentence -- see below
failureCategorystring \nullWhat the failure was about: vendor, destination, delivery, timeout, unknown. Always set when status is failed; null otherwise, expired included
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 failureCategory for what went wrong, failureReason for the raw token
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.

When a fax fails

A failed fax carries two fields, and they are for two different readers.

failureCategory is what you branch on and what you show a person. lib/domain/failure.ts:

CategoryWhat it meansShould the sender change anything?
vendorOur carrier account, config, or credentials -- or a defect in our dispatchNo. Nothing about the request was wrong. Retrying the same fax will not help until we fix it
destinationThe receiving line: busy, no answer, something that is not a fax machine, a number that will not dialMaybe -- worth checking the number
deliveryThe call connected and the pages did not get throughRetry is reasonable
timeoutStill undelivered an hour after payment, so we stopped waitingRetry is reasonable
unknownAn unmapped token, or a failure with no reason recordedQuote failureReason at support

failureReason is a raw carrier token and a support code, not a sentence. It comes from Telnyx's failure_reason, from our dispatch wrapper (telnyx_send_fax_422 and similar), or from our own reconciler (carrier_unknown_fax, delivery_timeout). Log it, quote it in a support request, key an internal metric on it -- but do not render it to an end user as the explanation.

The reason that rule is not stylistic: the token's wording can be actively misleading about whose fault it is. unverified_destination_not_allowed reads like a verdict on the number that was dialled. It is not. It means our Telnyx account was pending an upgrade, and it fires for every destination equally. It classifies as vendor for exactly that reason, and a client that printed the token would tell a blameless sender their number was rejected.

ts
const fax = await client.get(id)
if (fax.status === "failed") {
  const retryable = fax.failureCategory === "vendor" || fax.failureCategory === "timeout"
  // Show copy chosen by category; keep the token for the support path only.
  console.error(`fax ${fax.id} failed [${fax.failureCategory}] (${fax.failureReason})`)
}

Category never affects money. Every paid fax that misses delivered is refunded in full whatever the category (lib/payments/refund.ts), on the rails Stripe can reverse -- which includes every deposit-mode payment.


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), surfacing as status: "failed" with failureCategory: "destination". 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).

Do not confuse that with failureCategory: "vendor", which can also arrive carrying a token with "destination" in it (unverified_destination_not_allowed). That one is about our carrier account, not about where you addressed the fax. See When a fax fails.


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