# Sending a fax with MPP

MPP -- the Machine Payments Protocol (<https://mpp.dev>) -- is one of the two
wire dialects the SendFax 402 speaks. This guide decodes the challenge field by
field, shows how an MPP-capable client pays it, explains the relationship to
x402, and states plainly what settles money today versus what is advertised for
discovery.

That last part matters enough to put at the top.

> **Current state, stated honestly.** The MPP challenge on `POST /api/v1/faxes`
> is genuine -- issued by the real `mppx` challenge machinery, not a hand-rolled
> stub. But in the production configuration (Stripe deposit mode, the wired
> settlement path), the gate decides "paid" by polling a Stripe PaymentIntent and
> **never inspects an MPP credential**. Submitting a signed `PAYMENT` header does
> not settle; the direct USDC transfer described below does. The details, with
> line citations, are in [What actually settles](#what-actually-settles).

- Contract: <https://sendfax.sh/openapi.json> | docs: <https://sendfax.sh/docs>
- The stablecoin mechanics: [usdc.md](/guides/usdc.md)
- Platform guides: [README.md](/guides/index.md)

---

## The flow

```
  POST /api/v1/faxes                multipart: document + to
        |
        +--> 402 Payment Required
               |
               +-- WWW-Authenticate: Payment id=... method="evm" request=<b64url>
               |     the MPP dialect. Decode `request` for amount/token/chain.
               |
               +-- PAYMENT-REQUIRED: <base64>
               |     the SAME charge in x402 dialect.
               |
               +-- x-payment-deposit-*
                     the same charge again, as a deposit address to transfer to.
        |
        +--> one charge, three descriptions. Pick the one your client speaks.
        |
   MPP-native client                        direct-transfer client
   feed the header to your wallet           send exact USDC to the deposit
   retry with `PAYMENT: <credential>`       address, retry with NO header
        |                                             |
        +---------------------+-----------------------+
                              |
                    POST /api/v1/faxes  (identical body)
                              +--> 202 { id, statusUrl }
                              |
                    GET /api/v1/faxes/{id} -> delivered
```

## Which payment path am I in?

If your client library reads `WWW-Authenticate: Payment` and produces a
`PAYMENT` header, you are an MPP-native client -- read this whole page,
especially [What actually settles](#what-actually-settles), before you build
against it.

If your client just has a wallet, skip the dialect entirely: send the transfer
described in [usdc.md](/guides/usdc.md). The MPP challenge is informative, not required.

---

## Anatomy of the challenge

```
WWW-Authenticate: Payment id="cibtb1AF_IjPZk5Dk_pXiSxhf-pEYBrpGjFrPemcsO8",
                  realm="sendfax.sh",
                  method="evm",
                  intent="charge",
                  request="eyJhbW91bnQiOiI1MDAwMCIsImN1cnJlbmN5Ijoi...",
                  expires="2026-07-27T04:57:13.917Z"
```

An RFC 7235 challenge with scheme `Payment` and these auth-params:

| Param | Example | Meaning |
|---|---|---|
| `id` | `cibtb1AF_...` | Server-assigned challenge id. Also echoed in the body as `challengeId`. |
| `realm` | `sendfax.sh` | Authentication realm. |
| `method` | `evm` | The payment method. `evm` = an on-chain EVM charge. |
| `intent` | `charge` | What the challenge is for. |
| `request` | base64url JSON | The charge itself -- see below. |
| `expires` | ISO 8601 | When this challenge stops being valid. |

The 402 body is `application/problem+json` and repeats the challenge id:

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

### Decoding `request`

Base64url, unpadded. Decoded it is JSON:

```json
{
  "amount": "50000",
  "currency": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
  "recipient": "0x<the service's static receiving address>",
  "methodDetails": {
    "chainId": 8453,
    "credentialTypes": ["authorization"],
    "decimals": 6
  }
}
```

| Field | Type | Meaning |
|---|---|---|
| `amount` | string | The charge in the token's **base units**. `"50000"` = $0.05 of 6-decimal USDC. A string so a client never loses precision on a value that can exceed `Number.MAX_SAFE_INTEGER`. |
| `currency` | string | The ERC-20 contract address of the payment token. On Base mainnet this is USDC, `0x8335...2913`. |
| `recipient` | string | The address the *charge* is denominated to -- the service's static receiving wallet. **See the warning below.** |
| `methodDetails.chainId` | number | EVM chain id. `8453` = Base mainnet; `84532` = Base Sepolia in development. |
| `methodDetails.decimals` | number | Token decimals. `6` for USDC. |
| `methodDetails.credentialTypes` | string[] | The credential kinds the method understands, e.g. `["authorization"]` for an EIP-3009 signed authorization. |

Decode it in one line:

```sh
curl -sS -D - -o /dev/null -F document=@invoice.pdf -F to=+14155550123 \
  https://sendfax.sh/api/v1/faxes \
| grep -i '^www-authenticate' \
| sed -E 's/.*request="([^"]*)".*/\1/' \
| tr '_-' '/+' | base64 -d | jq
```

```ts
// Or in TypeScript, without a dependency.
const b64url = /request="([^"]*)"/.exec(headerValue)?.[1] ?? ""
const b64 = b64url.replace(/-/g, "+").replace(/_/g, "/")
const json = JSON.parse(atob(b64.padEnd(Math.ceil(b64.length / 4) * 4, "=")))
```

The `sendfax` npm SDK does exactly this and hands you a typed
`PaymentChallenge` (`sdk/src/challenge.ts:131-157`), with `raw.wwwAuthenticate`
preserved if you would rather parse it yourself.

> **`recipient` is not a deposit address.** It is the service's static
> `X402_PAY_TO_ADDRESS` (`lib/runtime/index.ts:185`). In the production
> deposit-mode configuration, USDC sent to `recipient` settles nothing -- the
> address that settles is the per-request one in `x-payment-deposit-address`
> (`lib/payments/gate.ts:219-234`). This is the single most expensive mistake
> available on this page.

---

## Paying it as an MPP-native client

The protocol shape is: read the challenge, produce a credential, retry the
identical request with a `PAYMENT` header.

```ts
import { SendFax, type PaymentHandler } from "sendfax"

const payment: PaymentHandler = async (challenge) => {
  // challenge.raw.wwwAuthenticate is the verbatim header; the parsed fields
  // (amountBaseUnits, tokenAddress, chainId, recipient, decimals, expiresAt)
  // are on `challenge` itself. sdk/src/challenge.ts:36-75
  const credential = await myMppWallet.authorize(challenge.raw.wwwAuthenticate!)
  return { headerName: "PAYMENT", headerValue: credential }
}

const client = new SendFax({ payment })
const receipt = await client.send({ to: "+14155550123", document: pdfBytes })
```

By hand, the retry is just the original request plus one header:

```sh
curl -sS -F document=@invoice.pdf -F to=+14155550123 \
  -H "PAYMENT: <credential your wallet produced>" \
  https://sendfax.sh/api/v1/faxes
```

On success the response is `202` with the receipt body, and mppx's receipt
headers are merged onto it (`web/app/api/v1/faxes/route.ts:166-176`) -- a
`Payment-Receipt` header, plus for the x402 dialect a `PAYMENT-RESPONSE`
settlement header (`lib/payments/gate.ts:332-337`).

---

## One endpoint, two dialects

There is exactly one charge behind both headers. The gate registers a single
mppx EVM `charge` method, and that method speaks both wire formats
(`lib/payments/gate.ts:8-23, 247-269`):

| | MPP | x402 |
|---|---|---|
| Challenge header | `WWW-Authenticate: Payment ...` | `PAYMENT-REQUIRED: <base64>` |
| Challenge payload | auth-params + base64url `request` | base64 `{ accepts: [...] }` |
| Retry header | `PAYMENT` | `X-PAYMENT` (or `PAYMENT-SIGNATURE`) |
| Settles as | the same on-chain USDC charge | the same on-chain USDC charge |
| Rail label in our telemetry | `mpp` | `x402` |

The rail label is purely *which dialect you spoke*, detected from your request
headers: a request carrying `payment-signature` or `x-payment` is labelled
`x402`, everything else `mpp` (`lib/payments/gate.ts:88-90, 196-200`). It is a
label, not a different code path.

The x402 challenge body looks like this:

```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.
A client that understands either dialect has everything it needs; a client that
understands neither can still read the `x-payment-deposit-*` headers.

---

<a name="what-actually-settles"></a>

## What actually settles

This section exists because the gap between "advertised" and "wired" is real,
and a guide that glossed it would waste your time.

The gate has three configurations (`lib/payments/gate.ts`):

**1. Mock mode** (`FAX_MODE=mock`, local development). Challenges are genuine
and well-formed, issued by real mppx machinery. The dev-only header
`x-mock-payment: <anything>` counts as paid without any settlement
(`gate.ts:284-293`). Use it to build against the true challenge shape without a
wallet. It does nothing in production.

**2. Stripe deposit mode** -- the production configuration. Enabled when
`STRIPE_DEPOSIT_X402=1` and a Stripe secret key is present
(`lib/runtime/index.ts:107`). In this mode:

- The mppx challenge is still issued verbatim, so MPP and x402 discovery are
  untouched (`gate.ts:317-322`).
- The 402 gains the additive `x-payment-deposit-*` headers advertising a fresh
  per-request Stripe deposit address (`gate.ts:219-234`).
- The verdict comes from `DepositService.settle()`, which polls the Stripe
  PaymentIntent for that request's payload hash (`gate.ts:299-314`). The rail is
  hardcoded `x402`.
- **Your `PAYMENT` header is never read.** The deposit branch runs before any
  mppx verification and returns from inside it. A signed MPP credential is
  accepted as wire-format discovery and settles nothing.

**3. Facilitator mode** -- deposit mode off, with `X402_FACILITATOR_URL` and
`MPP_SECRET_KEY` configured. Here the mppx charge really does verify your
credential and settle it on-chain through the facilitator (`gate.ts:247-255,
326-337`). This is the configuration in which an MPP `PAYMENT` header pays for
your fax.

Without a facilitator, the gate installs a settle stub that throws
`"PaymentGate: x402 facilitator not configured; cannot settle"`
(`gate.ts:249-255`) -- challenges are still issued correctly, but a real
submitted credential fails with that error rather than settling. The
configuration comment says this in as many words.

### What this means for you

| If you are | Do this |
|---|---|
| Building an MPP-native client today | Implement the `PAYMENT` retry, but **also** implement the deposit-transfer fallback, and expect the latter to be what pays. Treat a 402 on your paid retry as "deposit mode is on", not as a bug. |
| Building any client with a wallet | Just do the direct transfer ([usdc.md](/guides/usdc.md)). It is fewer moving parts and it is what settles. |
| Building against a self-hosted SendFax | Check which of the three configurations you are running. `x-payment-deposit-mode: stripe-deposit` on the 402 tells you immediately. |

Detecting the mode from a response is one header:

```ts
const depositMode = res.headers.get("x-payment-deposit-mode") === "stripe-deposit"
// depositMode  -> pay by transfer; a PAYMENT credential will not settle
// !depositMode -> the mppx charge is live; pay it with a PAYMENT header
```

---

## Refunds by rail

The refund policy differs by *how* the payment settled, which is a practical
reason to prefer the deposit path (`lib/payments/refund.ts:20-29`):

| Rail | Auto-refunded when undeliverable? |
|---|---|
| `stripe` (human Checkout) | Yes -- via the persisted Checkout session |
| `x402` settled by Stripe deposit mode | Yes -- `refunds.create({ payment_intent })` returns the USDC to your sending wallet |
| `mpp` / legacy on-chain x402 | **No** -- a raw on-chain transfer has no Stripe charge to reverse. Email support@sendfax.sh with the fax id |

An MPP-native payment that settles through a facilitator is a direct on-chain
transfer to the service wallet. There is no counterparty holding the funds in a
reversible state, so an undeliverable fax paid that way needs a manual refund.
Deposit-mode payments keep the automatic guarantee because they *are* Stripe
payments.

---

## Development against the real challenge shape

Run the dev server in `FAX_MODE=mock` and you get genuine 402s with no wallet:

```sh
# The challenge is real; the bypass header is the only thing that is not.
curl -sS -D - -o /dev/null \
  -F document=@invoice.pdf -F to=+14155550123 \
  http://localhost:3001/api/v1/faxes
# HTTP/1.1 402 Payment Required
# WWW-Authenticate: Payment id="...", realm="localhost", method="evm", ...

curl -sS -H 'x-mock-payment: 1' \
  -F document=@invoice.pdf -F to=+14155550123 \
  http://localhost:3001/api/v1/faxes | jq
# { "id": "...", "pageCount": 1, "amountUsdMicros": 50000, "statusUrl": "..." }
```

In mock mode the challenge advertises Base Sepolia (`chainId 84532`, the Sepolia
USDC contract) because the network defaults to `base-sepolia` when deposit mode
is off (`lib/runtime/index.ts:113-118`). Only the MPP dialect header is emitted;
the x402 `PAYMENT-REQUIRED` header appears once a facilitator is wired
(`sdk/README.md`, the "Two payment dialects" note). The SDK's parser handles
both regardless.

The SDK ships a captured mock-mode 402 as a test fixture
(`sdk/test/fixtures/402-challenge.json`) with the header, the decoded `request`
param, and the expected parse result -- useful as a conformance target if you
are writing a parser in another language.

---

## Building a parser: the awkward part

The `request` auth-param is base64url, which contains `-` and `_` but never a
comma. Other params are quoted strings which *can* contain commas. Splitting the
header on commas is the obvious approach and it is wrong.

Scan for `key=value` pairs instead, accepting both quoted-string and token68
forms (`sdk/src/challenge.ts:188-209`):

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

Token68 excludes comma and space, so any comma outside quotes genuinely
separates parameters. Lowercase the keys; auth-param names are case-insensitive.

Then be forgiving about what you find. A partial challenge is more useful than
an exception: the SDK returns a well-formed challenge with `amountBaseUnits:
"0"` and `null` fields rather than throwing (`sdk/src/challenge.ts:108-124`), so
that diagnostics and the raw header values survive a parse failure. Copy that
behaviour -- but log loudly, because a zeroed challenge usually means a missing
base64 or `TextDecoder` implementation in your runtime, not a server fault.

---

## Cross-references

- [usdc.md](/guides/usdc.md) -- the settlement mechanics, exact-amount rule, refunds
- [README.md](/guides/index.md) -- the chooser and the decision matrix
- [curl.md](/guides/curl.md) -- decode the challenge from a shell
- [react-native.md](/guides/react-native.md) -- the `PaymentHandler` contract in practice
- <https://mpp.dev> -- the protocol | <https://x402.org> -- the other dialect
- <https://sendfax.sh/openapi.json> | <https://sendfax.sh/docs>
