sendfax
v1.0.1
Published
Official TypeScript SDK for SendFax.sh — send real faxes over HTTP, paid per page with x402 or MPP (USDC). No accounts, no API keys.
Maintainers
Readme
sendfax
Official TypeScript SDK for SendFax.sh — send real faxes over HTTP, paid per page with x402 or MPP (USDC). No accounts, no API keys: payment is the credential.
- Zero runtime dependencies. Web-standard
fetch/FormData/Blobonly. - Runs in Node 18+, Bun, Deno, Cloudflare Workers, and the browser.
- ESM + CJS, fully typed, tree-shakeable (~2.5 KB min+gz).
npm install sendfaxQuickstart (agents)
An agent authorizes each fax by paying for it. You supply a payment handler that turns the API's 402 challenge into the header that pays for the retry — the SDK never bundles a wallet, so you keep full control of your signer.
import { SendFax, type PaymentHandler } from "sendfax"
// Wire your own x402/MPP signer here (pseudocode — the wallet lib is yours):
const payment: PaymentHandler = async (challenge) => {
// Sign a payment for `challenge.amountBaseUnits` of `challenge.tokenAddress`
// on `challenge.chainId`, paying `challenge.recipient`.
const signed = await myWallet.authorizeCharge(challenge)
return { headerName: "X-PAYMENT", headerValue: signed } // x402 dialect
// MPP-native client? return { headerName: "PAYMENT", headerValue: signed }
}
const client = new SendFax({ payment })
const receipt = await client.send({
to: "+14155550123",
document: pdfBytes // Uint8Array | ArrayBuffer | Blob
})
// receipt: { id, pageCount, amountUsdMicros, statusUrl }
const fax = await client.waitForDelivery(receipt.id, {
onStatus: (f) => console.log(f.status) // queued → media_processed → sending → delivered
})
console.log(fax.status) // "delivered"Wiring a real payment library
The SDK is deliberately signer-agnostic. Two common ways to build the handler:
x402-fetch— sign an EIP-3009 USDC authorization forchallenge.amountBaseUnitsonchallenge.chainIdand return it as{ headerName: "X-PAYMENT", headerValue }.- An MPP client (mpp.dev) — feed it
challenge.raw.wwwAuthenticateand return thePAYMENTheader it produces.
The challenge object gives you everything either needs:
amountBaseUnits (string, lossless), currency/tokenAddress, chainId,
recipient, decimals, expiresAt, challengeId, plus raw.wwwAuthenticate
and raw.paymentRequired if you'd rather parse the headers yourself.
Local development
Against a SendFax dev server in FAX_MODE=mock, use the built-in mock handler to
exercise the full flow without a wallet:
import { SendFax, mockPaymentHandler } from "sendfax"
const client = new SendFax({
baseUrl: "http://localhost:3001",
payment: mockPaymentHandler() // ⚠️ dev-only: sends x-mock-payment, settles nothing
})mockPaymentHandler() only works in mock mode — against production it does not
pay and every send still 402s.
Human note
This SDK covers the agent rails (x402 / MPP, per-request USDC). If you're a person who just wants to send a fax, use the web flow at sendfax.sh/human (drag a PDF, pay with Stripe Link, card, or USDC) — no code required.
Polling and waiting
// One-shot status (free, no payment):
const fax = await client.get(id)
// Poll until terminal, with control over cadence, timeout, and cancellation:
const controller = new AbortController()
const delivered = await client.waitForDelivery(id, {
intervalMs: 1500, // default
timeoutMs: 120_000, // default (2 min)
signal: controller.signal,
onStatus: (f) => updateUi(f)
})waitForDelivery resolves with the final FaxPublic on delivered, and rejects
with FaxFailedError on failed/expired (carrying failureReason), or a
SendFaxError on timeout/abort.
Error handling
Every rejection is a typed subclass of SendFaxError (with .status and the
parsed .problem body):
import {
SendFax,
PaymentRequiredError,
InvalidDocumentError,
InvalidDestinationError,
NotFoundError,
FaxFailedError,
ApiError
} from "sendfax"
try {
await client.send({ to, document })
} catch (err) {
if (err instanceof PaymentRequiredError) {
// No handler, or the handler couldn't satisfy the challenge.
console.log(err.challenge.amountBaseUnits, err.challenge.tokenAddress)
} else if (err instanceof InvalidDestinationError) {
// `to` was not a valid E.164 number.
} else if (err instanceof InvalidDocumentError) {
// Not a PDF, encrypted, zero pages, or > 20 MB.
} else if (err instanceof ApiError) {
// Any other non-2xx: inspect err.status / err.problem.
}
}| Error | When |
|---|---|
| PaymentRequiredError | 402 — carries the parsed challenge |
| InvalidDocumentError | bad/encrypted/empty/too-large PDF (400 / 413 / 422) |
| InvalidDestinationError | to not E.164 (422) |
| NotFoundError | unknown fax id (404) |
| FaxFailedError | waitForDelivery saw failed/expired — has failureReason |
| ApiError | any other non-2xx |
API
new SendFax({ baseUrl?, fetch?, payment? })—baseUrldefaults tohttps://sendfax.sh; inject a customfetch; setpaymentto auto-pay 402s.send({ to, document, filename? })→SendFaxResult— sends multipart; on 402 with a handler, pays and retries once; returns{ id, pageCount, amountUsdMicros, statusUrl }.get(id)→FaxPublic— free status lookup.waitForDelivery(id, { intervalMs?, timeoutMs?, signal?, onStatus? })→FaxPublic— polls to a terminal state.
Pricing
Flat $0.05/page in USDC (amountUsdMicros of 50000 per page; no minimums,
no top-ups).
Design
A few conventions worth knowing if you're extending or auditing the SDK:
/openapi.jsonis the contract. Every type here is a projection of the API's OpenAPI document (served athttps://sendfax.sh/openapi.json), backed by the route handlers inapp/api/v1. The SDK never invents fields the API doesn't return.- Two payment dialects, one charge. The 402 advertises the same on-chain USDC
charge as either MPP (
WWW-Authenticate: Payment …, retry withPAYMENT) or x402 (PAYMENT-REQUIRED: <base64>, retry withX-PAYMENT).parseChallengehandles whichever is present and preserves both raw header values onchallenge.raw. InFAX_MODE=mock— and until an x402 facilitator is wired — the live API emits only the MPP header; the x402 one is parser-supported but absent. - The PaymentHandler pattern keeps the SDK dependency-free. Rather than
bundling a wallet, the SDK exposes one extension point —
(challenge, attempt) => { headerName, headerValue }— that you wire your own signer into.mockPaymentHandler()covers dev; real x402/MPP wiring is documented above, not shipped as a dependency. - Error taxonomy. Every rejection is a
SendFaxError, mapped from HTTP status- the RFC 9457
problem+jsonbody:PaymentRequiredError(402, carries the challenge),InvalidDocumentError(400/413/422 document errors),InvalidDestinationError(422, non-E.164to),NotFoundError(404),ApiError(any other non-2xx), plusFaxFailedErrorfromwaitForDelivery.
- the RFC 9457
- Conformance fixtures.
test/fixtures/*.jsonare real responses captured from the live API (not hand-authored); the unit tests assert the parser against them. Integration tests intest/run against a local dev server and skip gracefully when it's unreachable.
This package is TypeScript-only today; the structure keeps a future Python/Go SDK possible without reshaping anything here.
Links
- Docs & quickstart: https://sendfax.sh/docs
- OpenAPI: https://sendfax.sh/openapi.json
- MCP server:
https://mcp.sendfax.sh/mcp
MIT © SendFax.sh
