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.

sh
npm install sendfax        # or: bun add sendfax / deno add npm:sendfax

The two flows

text
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)  ->  delivered

Which 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

RuntimeWorksNotes
Node 18+yesengines.node: ">=18" in sdk/package.json. Global fetch, FormData, Blob, atob, TextDecoder are all native
Node 16 and belownoNo global fetch. Pass one in via new SendFax({ fetch }) if you must
BunyesEverything native
Denoyesimport { SendFax } from "npm:sendfax"
Cloudflare WorkersyesSee cloudflare-workers.md
BrowsersyesSee nextjs.md -- the interesting part there is CORS, not the SDK
React Native / Hermeswith three shimsTextDecoder, 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

ts
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

MethodReturnsNotes
new SendFax({ baseUrl?, fetch?, payment? })baseUrl defaults to https://sendfax.sh
send({ to, document, filename? })SendFaxResultSends multipart; on 402 with a handler, pays and retries once
get(id)FaxPublicFree status lookup
waitForDelivery(id, { intervalMs?, timeoutMs?, signal?, onStatus? })FaxPublicPolls 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:

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

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

sh
npm install viem

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

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

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

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

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

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

ts
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.
  }
}
ErrorWhen
PaymentRequiredError402 -- carries the parsed challenge
InvalidDocumentErrorbad/encrypted/empty/too-large PDF (400 / 413 / 422)
InvalidDestinationError422 with a destination-related title
NotFoundError404 -- unknown fax id
FaxFailedErrorwaitForDelivery saw failed/expired; has failureReason
ApiErrorany 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.

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

VariableRequiredMeaning
SENDFAX_PRIVATE_KEYfor autonomous mode0x... EVM key holding USDC and a little ETH on Base
SENDFAX_APInoDefaults to https://sendfax.sh
SENDFAX_MAX_USDnoSpend ceiling per fax; defaults to 1.00
SENDFAX_RPC_URLnoBase RPC; defaults to viem's public endpoint
SENDFAX_MOCKno1 uses the dev bypass header (mock servers only)

Never put a key on the command line -- it lands in shell history and in ps.

ts
#!/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
}
sh
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