# 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](/guides/usdc.md) | the MPP dialect: [mpp.md](/guides/mpp.md)
- A runnable version of this page: [curl.md](/guides/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 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](/guides/index.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](/guides/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:

```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](/guides/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:

```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

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

> **`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:

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

```
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.sh
```

```
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
}
```

| 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 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."
}
```

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

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

| 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](/guides/curl.md) -- this page, runnable
- [usdc.md](/guides/usdc.md) -- the on-chain half in depth
- [mpp.md](/guides/mpp.md) -- the MPP dialect field by field
- [typescript.md](/guides/typescript.md) | [nextjs.md](/guides/nextjs.md) | [cloudflare-workers.md](/guides/cloudflare-workers.md)
- [macos.md](/guides/macos.md) | [ios.md](/guides/ios.md) | [react-native.md](/guides/react-native.md)
- <https://sendfax.sh/openapi.json> | <https://sendfax.sh/docs>
