Sending a fax with MPP
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/faxesis genuine -- issued by the realmppxchallenge 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 signedPAYMENTheader does not settle; the direct USDC transfer described below does. The details, with line citations, are in What actually settles.
- Contract: https://sendfax.sh/openapi.json | docs: https://sendfax.sh/docs
- The stablecoin mechanics: usdc.md
- Platform guides: README.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} -> deliveredWhich 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, before you build against it.
If your client just has a wallet, skip the dialect entirely: send the transfer described in 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:
{
"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:
{
"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:
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// 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.
recipientis not a deposit address. It is the service's staticX402_PAY_TO_ADDRESS(lib/runtime/index.ts:185). In the production deposit-mode configuration, USDC sent torecipientsettles nothing -- the address that settles is the per-request one inx-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.
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:
curl -sS -F document=@invoice.pdf -F to=+14155550123 \
-H "PAYMENT: <credential your wallet produced>" \
https://sendfax.sh/api/v1/faxesOn 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:
{
"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.
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 hardcodedx402. - Your
PAYMENTheader 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). 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:
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 headerRefunds 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:
# 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]+))/gToken68 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 -- the settlement mechanics, exact-amount rule, refunds
- README.md -- the chooser and the decision matrix
- curl.md -- decode the challenge from a shell
- react-native.md -- the
PaymentHandlercontract in practice - https://mpp.dev -- the protocol | https://x402.org -- the other dialect
- https://sendfax.sh/openapi.json | https://sendfax.sh/docs