Sending a fax with TypeScript
Sending a fax with TypeScript
The sendfax npm package is the official SDK: zero runtime dependencies, ESM + CJS, ~2.5 KB min+gz, and built on web-standard fetch, FormData and Blob -- so it runs unmodified on Node 18+, Bun, Deno, Cloudflare Workers, and in browsers.
This guide covers the SDK end to end, wiring a PaymentHandler to a local key with viem, and the plain-fetch version for when you would rather not take a dependency at all.
npm install sendfax # or: bun add sendfax / deno add npm:sendfax- SDK source:
sdk/src/| reference:sdk/README.md - Wire format: http.md | settlement: usdc.md
The two flows
FLOW A -- no wallet
new SendFax() [no PaymentHandler]
+-> send() throws PaymentRequiredError <-- the fork, not a failure
+-> POST /api/v1/uploads (plain fetch; the SDK has no upload method)
+-> POST /api/v1/checkout -> { url }
+-> open the URL in a browser
+-> client.waitForDelivery(id) -> delivered
FLOW B -- wallet
new SendFax({ fetch: capturing, payment: handler })
+-> send() -> 402 -> handler pays -> retry
| handler: viem sends exact USDC on Base to the deposit address
+-> outer loop re-calls send() until 202 (see "One retry is not enough")
+-> client.waitForDelivery(id) -> deliveredWhich payment path am I in?
Can this process move USDC on Base? A private key in the environment, an embedded signer, a custodial API -- Flow B. Nothing -- Flow A. In SDK terms the choice is one constructor option: pass payment and send() settles inline; omit it and send() throws PaymentRequiredError, which is your cue to run the Stripe flow (sdk/src/client.ts:113-128).
Runtime support
| Runtime | Works | Notes |
|---|---|---|
| Node 18+ | yes | engines.node: ">=18" in sdk/package.json. Global fetch, FormData, Blob, atob, TextDecoder are all native |
| Node 16 and below | no | No global fetch. Pass one in via new SendFax({ fetch }) if you must |
| Bun | yes | Everything native |
| Deno | yes | import { SendFax } from "npm:sendfax" |
| Cloudflare Workers | yes | See cloudflare-workers.md |
| Browsers | yes | See nextjs.md -- the interesting part there is CORS, not the SDK |
| React Native / Hermes | with three shims | TextDecoder, Blob-from-bytes, and the FormData filename. See react-native.md |
The SDK uses no crypto APIs at all -- verified across sdk/src. Wallet libraries do, but that is their dependency, not the SDK's.
The quickest version
import { SendFax } from "sendfax"
import { readFile } from "node:fs/promises"
const client = new SendFax({ payment: myHandler }) // see below
const receipt = await client.send({
to: "+14155550123",
document: await readFile("invoice.pdf") // Uint8Array | ArrayBuffer | Blob
})
// { id, pageCount, amountUsdMicros, statusUrl }
const fax = await client.waitForDelivery(receipt.id, {
onStatus: (f) => console.log(f.status) // queued -> sending -> delivered
})
console.log(fax.status) // "delivered"The API surface
| Method | Returns | Notes |
|---|---|---|
new SendFax({ baseUrl?, fetch?, payment? }) | baseUrl defaults to https://sendfax.sh | |
send({ to, document, filename? }) | SendFaxResult | Sends multipart; on 402 with a handler, pays and retries once |
get(id) | FaxPublic | Free status lookup |
waitForDelivery(id, { intervalMs?, timeoutMs?, signal?, onStatus? }) | FaxPublic | Polls to a terminal state |
waitForDelivery defaults to a 1500 ms interval and a 120 s timeout (sdk/src/client.ts:162-163). It resolves on delivered, and rejects with FaxFailedError on failed/expired (carrying failureReason) or a SendFaxError on timeout or abort.
Flow A -- no wallet: catch, then Stripe
send() without a PaymentHandler throws PaymentRequiredError on the 402 (sdk/src/client.ts:115). That is the branch, not an error condition.
The SDK covers the agent rail only (sdk/src/index.ts) -- there is no upload() or checkout(). The human rail is two plain fetch calls:
import { SendFax, PaymentRequiredError } from "sendfax"
import { readFile } from "node:fs/promises"
const BASE = "https://sendfax.sh"
const client = new SendFax() // no handler -> Flow A
async function sendWithCard(path: string, to: string) {
const bytes = await readFile(path)
try {
const receipt = await client.send({ to, document: bytes })
return receipt.id // a handler was configured after all
} catch (err) {
if (!(err instanceof PaymentRequiredError)) throw err
// --- the fork ---------------------------------------------------------
const form = new FormData()
form.append("document", new Blob([bytes], { type: "application/pdf" }), "document.pdf")
form.append("to", to)
const up = await fetch(`${BASE}/api/v1/uploads`, { method: "POST", body: form })
if (!up.ok) throw await problem(up)
// amountCents is max(50, ceil(pages * 5)). lib/domain/pricing.ts:23
const { id, pageCount, amountCents } =
(await up.json()) as { id: string; pageCount: number; amountCents: number }
const co = await fetch(`${BASE}/api/v1/checkout`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ id })
})
if (!co.ok) throw await problem(co)
const { url } = (await co.json()) as { url: string }
console.log(`${pageCount} page(s), $${(amountCents / 100).toFixed(2)}`)
console.log(`Pay here: ${url}`)
return id
}
}
async function problem(res: Response) {
const body = (await res.json().catch(() => null)) as { detail?: string; title?: string } | null
return new Error(body?.detail ?? body?.title ?? `HTTP ${res.status}`)
}Then poll with the SDK, which works regardless of how payment happened:
const id = await sendWithCard("invoice.pdf", "+14155550123")
const fax = await client.waitForDelivery(id, { timeoutMs: 600_000 })Nothing tells you when the card payment lands -- Checkout redirects to https://sendfax.sh/f/{id}?paid=1 (lib/payments/stripe.ts:139), not to you. Polling is the mechanism, which is why waitForDelivery gets a long timeout here.
Flow B -- wallet: a PaymentHandler with viem
A PaymentHandler is (challenge, attempt) => Promise<{ headerName, headerValue }> (sdk/src/payment.ts:45-48). The SDK calls it once on the 402 and retries the request with that header.
Mapping that onto deposit-mode settlement takes three adaptations. They are workarounds for real gaps, so they are worth understanding rather than pasting.
npm install viemAdaptation 1: capture the deposit headers
parseChallenge reads only WWW-Authenticate and PAYMENT-REQUIRED (sdk/src/challenge.ts:92-125). It does not read the x-payment-deposit-* headers, so challenge.recipient is the service's static receiving address (lib/runtime/index.ts:185), not the per-request deposit address. Sending to challenge.recipient loses the money.
The SDK's fetch injection point is the fix:
const USDC_BASE = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" as const
export type DepositQuote = {
network: string
address: `0x${string}`
token: `0x${string}`
amountDisplay: string // "0.050000" -- for logs and humans
amountBaseUnits: bigint // 50000n -- for the transfer
paymentIntent: string // "pi_..." -- persist before paying
}
/** Captures what the SDK's challenge parser drops. gate.ts:219-234 */
export function depositCapture() {
let latest: DepositQuote | null = null
const capturingFetch: typeof fetch = async (input, init) => {
const res = await fetch(input, init)
if (res.status === 402) {
const h = res.headers
const address = h.get("x-payment-deposit-address")
const micros = h.get("x-payment-deposit-amount-micros")
if (address && micros) {
latest = {
network: h.get("x-payment-deposit-network") ?? "base",
address: address as `0x${string}`,
token: (h.get("x-payment-deposit-token") ?? USDC_BASE) as `0x${string}`,
amountDisplay: h.get("x-payment-deposit-amount") ?? "",
// Integer base units. Deposit mode matches EXACTLY, so never round-trip
// the decimal display string through a float. deposit.ts:16-18
amountBaseUnits: BigInt(micros),
paymentIntent: h.get("x-payment-deposit-payment-intent") ?? ""
}
}
}
return res
}
return { capturingFetch, quote: () => latest }
}Adaptation 2: return a header you do not have
The handler's return type is required (sdk/src/payment.ts:19-24), but deposit mode has no credential -- the retry itself settles. Return an informational header. The gate ignores request headers entirely on the deposit path: it hardcodes rail: "x402" and decides from the PaymentIntent (lib/payments/gate.ts:299-324).
Pick a name outside the payment namespace. x-payment and payment-signature are the two the gate sniffs for rail labelling (lib/payments/gate.ts:88-90, 196-200) -- inert in deposit mode, but if the server is ever reconfigured with a facilitator they would be read as a real credential and fail verification. x-sendfax-deposit-tx carries the transaction hash, is useful in a support ticket, and can never be mistaken for one.
import { createWalletClient, http, encodeFunctionData, erc20Abi } from "viem"
import { privateKeyToAccount } from "viem/accounts"
import { base } from "viem/chains"
import type { PaymentHandler } from "sendfax"
export function depositHandler(
quoteOf: () => DepositQuote | null,
opts: { privateKey: `0x${string}`; maxMicros: bigint; rpcUrl?: string }
): PaymentHandler {
const account = privateKeyToAccount(opts.privateKey)
const wallet = createWalletClient({ account, chain: base, transport: http(opts.rpcUrl) })
let fundedIntent: string | null = null
let txHash = "pending"
return async () => {
const quote = quoteOf()
if (!quote) throw new Error("402 carried no x-payment-deposit-* headers")
// The outer loop calls send() repeatedly, so the handler runs more than
// once. Pay per PaymentIntent, not per attempt.
if (fundedIntent !== quote.paymentIntent) {
// Enforce limits in code. The amount arrived over the wire from a remote
// party -- which is still true when the remote party is us.
if (quote.network !== "base") throw new Error(`unexpected network ${quote.network}`)
if (quote.token.toLowerCase() !== USDC_BASE.toLowerCase())
throw new Error(`unexpected token ${quote.token}`)
if (quote.amountBaseUnits > opts.maxMicros)
throw new Error(`charge ${quote.amountDisplay} exceeds the configured ceiling`)
txHash = await wallet.sendTransaction({
to: quote.token, // the USDC contract
data: encodeFunctionData({
abi: erc20Abi,
functionName: "transfer",
args: [quote.address, quote.amountBaseUnits] // the deposit address
})
})
fundedIntent = quote.paymentIntent
}
// Deposit mode has no credential: the retry itself settles, and the gate
// never reads request headers on this path. Informational only.
return { headerName: "x-sendfax-deposit-tx", headerValue: txHash }
}
}Adaptation 3: one retry is not enough
send() pays and retries exactly once, then throws PaymentRequiredError if that retry is still a 402 (sdk/src/client.ts:117-127). A USDC transfer on Base needs seconds to land and for Stripe to report the PaymentIntent as processing (lib/payments/deposit.ts:78-83), so the single retry will usually still 402. That is expected, not a failure.
The outer loop is safe because the server correlates retries by hashing to + amount + document bytes and reuses the same PaymentIntent for 15 minutes (lib/payments/deposit.ts:50, 94-108). Identical send() calls are not a double charge, and the handler's fundedIntent guard means the wallet pays once.
import { SendFax, PaymentRequiredError, type SendFaxResult } from "sendfax"
export async function sendWithWallet(
document: Uint8Array,
to: string,
opts: { privateKey: `0x${string}`; maxMicros?: bigint }
): Promise<SendFaxResult> {
const { capturingFetch, quote } = depositCapture()
const client = new SendFax({
fetch: capturingFetch,
payment: depositHandler(quote, {
privateKey: opts.privateKey,
maxMicros: opts.maxMicros ?? 1_000_000n // $1.00
})
})
// Stay inside the 15-minute deposit reuse window.
const deadline = Date.now() + 14 * 60_000
let delay = 3000
while (Date.now() < deadline) {
try {
return await client.send({ to, document }) // 202
} catch (err) {
if (!(err instanceof PaymentRequiredError)) throw err // 422 etc: give up
await new Promise((r) => setTimeout(r, delay))
delay = Math.min(Math.round(delay * 1.5), 15_000)
}
}
throw new Error("deposit not detected within the 15-minute window")
}Put it together:
const receipt = await sendWithWallet(await readFile("invoice.pdf"), "+14155550123", {
privateKey: process.env.SENDFAX_PRIVATE_KEY as `0x${string}`
})
const fax = await new SendFax().waitForDelivery(receipt.id, {
onStatus: (f) => console.error(f.status)
})Wallet funding
The key needs USDC on Base for the fax and a little ETH on Base for gas. Gas is typically a fraction of a cent, but a zero-ETH wallet cannot transfer USDC at all -- the most common first-run failure, and it looks like a SendFax problem when it is not. Check both balances before you trust a cron job with it.
The mock handler, for development
Against a dev server in FAX_MODE=mock, mockPaymentHandler() sends the x-mock-payment bypass and every send settles instantly with no wallet (sdk/src/payment.ts:66-68, honored at lib/payments/gate.ts:284-293):
import { SendFax, mockPaymentHandler } from "sendfax"
const dev = process.env.NODE_ENV !== "production"
const client = new SendFax({
baseUrl: dev ? "http://localhost:3001" : "https://sendfax.sh",
payment: dev ? mockPaymentHandler() : realHandler
})Mock mode also drives the status lifecycle on read -- one step per GET, about 3 seconds apart (web/app/api/v1/faxes/[id]/route.ts:24-32) -- so waitForDelivery reaches delivered in roughly 15 seconds. Never ship mockPaymentHandler to production: it pays nothing and every send still 402s.
Errors
Every rejection is a typed subclass of SendFaxError, carrying .status and the parsed .problem body:
import {
PaymentRequiredError,
InvalidDestinationError,
InvalidDocumentError,
NotFoundError,
FaxFailedError,
ApiError
} from "sendfax"
try {
await client.send({ to, document })
} catch (err) {
if (err instanceof InvalidDestinationError) {
// 422. Not E.164, or outside US/CA/MX/PM. `err.problem.detail` is the
// exact sentence to show. lib/domain/fax.ts:84-85
console.error(err.problem?.detail ?? err.message)
} else if (err instanceof InvalidDocumentError) {
// 400 / 413 / 422: not a PDF, encrypted, zero pages, or over 20 MB.
} else if (err instanceof PaymentRequiredError) {
// No handler, or the handler could not satisfy the challenge.
console.error(err.challenge.amountBaseUnits, err.challenge.tokenAddress)
} 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 | 422 with a destination-related title |
NotFoundError | 404 -- unknown fax id |
FaxFailedError | waitForDelivery saw failed/expired; has failureReason |
ApiError | any other non-2xx |
Note that both 422 cases share a status code, so the SDK routes on the problem title (sdk/src/errors.ts:117-120). If you write your own client, do the same.
Without the SDK
The SDK is 2.5 KB and dependency-free, so "avoid the dependency" is rarely worth it -- but the whole flow is short enough that here it is, in plain fetch, for when you want to see it or cannot add a package.
import { readFile } from "node:fs/promises"
const BASE = "https://sendfax.sh"
type Receipt = { id: string; pageCount: number; amountUsdMicros: number; statusUrl: string }
type FaxPublic = {
id: string
status: "awaiting_payment" | "paid" | "queued" | "media_processed"
| "sending" | "delivered" | "failed" | "expired"
pageCount: number
toMasked: string
failureReason: string | null
createdAt: number
terminalAt: number | null
}
const TERMINAL = ["delivered", "failed", "expired"]
async function sendFax(path: string, to: string, pay: (q: Quote) => Promise<void>) {
const bytes = await readFile(path)
// Build fresh each attempt; the BYTES must be identical, the envelope need not be.
const body = () => {
const form = new FormData()
form.append("document", new Blob([bytes], { type: "application/pdf" }), "document.pdf")
form.append("to", to)
return form
}
let res = await fetch(`${BASE}/api/v1/faxes`, { method: "POST", body: body() })
if (res.status === 202) return (await res.json()) as Receipt
if (res.status !== 402) throw await problem(res)
const quote = readQuote(res)
if (!quote) throw new Error("402 carried no deposit headers")
await pay(quote)
const deadline = Date.now() + 14 * 60_000
let delay = 3000
while (Date.now() < deadline) {
await new Promise((r) => setTimeout(r, delay))
res = await fetch(`${BASE}/api/v1/faxes`, { method: "POST", body: body() })
if (res.status === 202) return (await res.json()) as Receipt
if (res.status !== 402) throw await problem(res)
delay = Math.min(Math.round(delay * 1.5), 15_000)
}
throw new Error("deposit not detected within the 15-minute window")
}
type Quote = { address: string; token: string; network: string; amountBaseUnits: bigint }
function readQuote(res: Response): Quote | null {
const h = res.headers
const address = h.get("x-payment-deposit-address")
const micros = h.get("x-payment-deposit-amount-micros")
if (!address || !micros) return null
return {
address,
token: h.get("x-payment-deposit-token") ?? "",
network: h.get("x-payment-deposit-network") ?? "base",
amountBaseUnits: BigInt(micros)
}
}
async function waitForDelivery(id: string, timeoutMs = 300_000): Promise<FaxPublic> {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
const res = await fetch(`${BASE}/api/v1/faxes/${encodeURIComponent(id)}`)
if (!res.ok) throw await problem(res)
const fax = (await res.json()) as FaxPublic
if (TERMINAL.includes(fax.status)) return fax
await new Promise((r) => setTimeout(r, 2000))
}
throw new Error(`timed out waiting for ${id}`)
}
async function problem(res: Response) {
const b = (await res.json().catch(() => null)) as { detail?: string; title?: string } | null
return new Error(b?.detail ?? b?.title ?? `HTTP ${res.status}`)
}What you give up by hand-rolling it: the error taxonomy, the challenge parser, abort-signal support in the poll loop, and the conformance fixtures the SDK's tests assert against.
A complete CLI
Putting Flow A and Flow B behind one command, with env-var config and exit codes.
| Variable | Required | Meaning |
|---|---|---|
SENDFAX_PRIVATE_KEY | for autonomous mode | 0x... EVM key holding USDC and a little ETH on Base |
SENDFAX_API | no | Defaults to https://sendfax.sh |
SENDFAX_MAX_USD | no | Spend ceiling per fax; defaults to 1.00 |
SENDFAX_RPC_URL | no | Base RPC; defaults to viem's public endpoint |
SENDFAX_MOCK | no | 1 uses the dev bypass header (mock servers only) |
Never put a key on the command line -- it lands in shell history and in ps.
#!/usr/bin/env bun
/**
* sendfax <file.pdf> <+E164>
* Exit: 0 delivered - 2 usage - 3 bad destination - 4 bad document
* 5 payment not settled - 6 delivery failed - 1 other
*/
import { readFile } from "node:fs/promises"
import {
SendFax,
PaymentRequiredError,
InvalidDestinationError,
InvalidDocumentError,
FaxFailedError,
mockPaymentHandler
} from "sendfax"
const API = process.env.SENDFAX_API ?? "https://sendfax.sh"
const MAX = BigInt(Math.round(Number(process.env.SENDFAX_MAX_USD ?? "1.00") * 1_000_000))
const [file, to] = process.argv.slice(2)
if (!file || !to) {
console.error("usage: sendfax <file.pdf> <+E164>")
process.exit(2)
}
const bytes = await readFile(file)
const { capturingFetch, quote } = depositCapture()
const payment =
process.env.SENDFAX_MOCK === "1"
? mockPaymentHandler()
: process.env.SENDFAX_PRIVATE_KEY
? depositHandler(quote, {
privateKey: process.env.SENDFAX_PRIVATE_KEY as `0x${string}`,
maxMicros: MAX,
rpcUrl: process.env.SENDFAX_RPC_URL
})
: undefined
const client = new SendFax({ baseUrl: API, fetch: capturingFetch, payment })
const deadline = Date.now() + 14 * 60_000
let delay = 3000
let receipt
while (!receipt) {
try {
receipt = await client.send({ to, document: bytes })
} catch (err) {
if (err instanceof InvalidDestinationError) {
console.error(err.problem?.detail ?? err.message)
process.exit(3)
}
if (err instanceof InvalidDocumentError) {
console.error(err.problem?.detail ?? err.message)
process.exit(4)
}
if (!(err instanceof PaymentRequiredError)) throw err
if (!payment) {
const q = quote()
console.error(
q
? `payment required: send exactly ${q.amountDisplay} USDC on ${q.network} to ${q.address}`
: `payment required: ${err.challenge.amountBaseUnits} base units`
)
process.exit(5)
}
if (Date.now() > deadline) {
console.error("deposit not detected within the 15-minute window")
process.exit(5)
}
await new Promise((r) => setTimeout(r, delay))
delay = Math.min(Math.round(delay * 1.5), 15_000)
}
}
console.error(
`accepted ${receipt.id} (${receipt.pageCount} page(s), ` +
`$${(receipt.amountUsdMicros / 1_000_000).toFixed(2)})`
)
console.error(receipt.statusUrl)
try {
const fax = await client.waitForDelivery(receipt.id, {
intervalMs: 2000,
timeoutMs: 600_000,
onStatus: (f) => console.error(f.status)
})
console.log(JSON.stringify(fax)) // stdout: the machine-readable result
process.exit(0)
} catch (err) {
if (err instanceof FaxFailedError) {
console.error(`failed: ${err.failureReason ?? "unknown"}`)
console.log(JSON.stringify(err.fax))
process.exit(6)
}
throw err
}export SENDFAX_PRIVATE_KEY=0x... # from a secret manager, not your shell history
export SENDFAX_MAX_USD=0.50
bun sendfax.ts invoice.pdf +14155550123
# accepted 133718a5-... (1 page(s), $0.05)
# https://sendfax.sh/f/133718a5-...
# queued
# sending
# delivered
# {"id":"133718a5-...","status":"delivered",...}Status chatter goes to stderr and the final FaxPublic JSON to stdout, so bun sendfax.ts doc.pdf +1415... | jq -r .terminalAt works in a pipeline.
For Node, swap the shebang for #!/usr/bin/env node and run through tsx, or compile first. Node 18+ has global fetch, FormData and Blob, which is everything the SDK needs (sdk/src/client.ts:3-6).
Edge cases
422 costs nothing. to is validated and unsupported countries rejected at web/app/api/v1/faxes/route.ts:59-64, before the gate runs at line 81. No deposit address is minted. Surface err.problem?.detail verbatim.
A +1 number can still fail at the carrier. +1 is the whole NANP, so +1876 passes validation and fails at Telnyx (lib/domain/fax.ts:87-97). 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).
The amount must be exact. Use amountBaseUnits from x-payment-deposit-amount-micros. An over- or under-payment cannot be auto-matched (lib/payments/deposit.ts:16-18, 78-80).
Per-request address, ~15-minute reuse window (lib/payments/deposit.ts:50). Past it a new address is minted and the earlier transfer is stranded. For anything unattended, log quote.paymentIntent before broadcasting.
Retrying after 202 is idempotent (web/app/api/v1/faxes/route.ts:93-111).
processing, not succeeded, is the settlement bar (lib/payments/deposit.ts:78-83). Your 202 can precede deep confirmation.
document accepts Uint8Array | ArrayBuffer | Blob (sdk/src/client.ts:40) and is buffered internally so the request can be retried. filename defaults to "document.pdf".
Pricing differs by rail. $0.05/page by wallet; max($0.50, $0.05 x pages) by card (lib/domain/pricing.ts:16, 23).
Cross-references
- http.md -- the wire format this SDK is a projection of
- curl.md -- the same flow with no runtime at all
- cloudflare-workers.md -- the SDK on workerd
- nextjs.md -- the same SDK in a browser, plus CORS
sdk/README.md-- full SDK reference- https://sendfax.sh/openapi.json | https://sendfax.sh/docs