Sending a fax from a Cloudflare Worker
Sending a fax from a Cloudflare Worker
Workers are a good host for this: the whole SendFax flow is fetch plus a poll loop, which is exactly what workerd is fast at. The sendfax SDK runs unmodified -- it uses only web-standard fetch, FormData and Blob (sdk/src/client.ts:3-6), all of which workerd implements natively, and the package advertises Workers support explicitly in sdk/README.md.
What is different from Node: there is no filesystem, so the PDF has to come from somewhere else; secrets come from bindings rather than process.env; and a request-scoped Worker will be torn down the moment you return a response unless you tell it otherwise.
npm install sendfax- SDK:
sdk/README.md| wire format: http.md - TypeScript specifics: typescript.md | settlement: usdc.md
The two flows
Worker request / queue message / cron
|
get the PDF bytes (no fs!)
+-- from the incoming request body
+-- from R2: (await env.DOCS.get(key)).arrayBuffer()
+-- from KV: env.DOCS.get(key, "arrayBuffer")
+-- from a URL: (await fetch(url)).arrayBuffer()
|
FLOW A -- no wallet FLOW B -- wallet
POST /api/v1/uploads POST /api/v1/faxes
+-> { id, pageCount, amountCents } +-> 402 + x-payment-deposit-*
POST /api/v1/checkout -> { url } viem: exact USDC transfer on Base
return the url to your caller retry identical request -> 202
| |
+---------------------+----------------------+
|
ctx.waitUntil(pollToTerminal(id)) or a Queue / Durable ObjectWhich payment path am I in?
Does this Worker hold a key that can move USDC on Base? A wrangler secret holding a private key means Flow B and full autonomy. No key means Flow A: return the Checkout URL to whoever called you and let a human pay.
Think hard before choosing Flow B. A Worker with a funded key is an HTTP endpoint that spends money -- put authentication in front of it, and a ceiling in the code.
Getting the bytes
There is no node:fs. Every source below produces an ArrayBuffer, which is what both the SDK and a hand-built FormData want.
// 1. Straight from the incoming request (a client uploading to your Worker).
const bytes = await request.arrayBuffer()
// 2. From a multipart upload your Worker received.
const form = await request.formData()
const file = form.get("document")
if (!(file instanceof File)) return new Response("expected a document", { status: 400 })
const bytes = await file.arrayBuffer()
// 3. From R2 -- the usual home for a stored PDF.
const object = await env.DOCS.get(key)
if (!object) return new Response("not found", { status: 404 })
const bytes = await object.arrayBuffer()
// 4. From KV. Note KV values are capped at 25 MB, and our limit is 20 MB anyway.
const bytes = await env.DOCS.get(key, "arrayBuffer")
// 5. From any URL.
const bytes = await (await fetch(pdfUrl)).arrayBuffer()Check the size before sending. Over 20 MB the API answers 413 (lib/domain/pricing.ts:42, enforced at web/app/api/v1/faxes/route.ts:65-67):
const MAX_PDF_BYTES = 20 * 1024 * 1024
if (bytes.byteLength > MAX_PDF_BYTES) {
return new Response("PDF exceeds the 20 MB limit", { status: 413 })
}Building the multipart body
If you use the SDK, skip this -- send() builds it. By hand:
const form = new FormData()
// The `document` part MUST carry a filename: the API checks it parsed as a file
// part and answers 400 "Missing document" otherwise. faxes/route.ts:56-58
form.append("document", new Blob([bytes], { type: "application/pdf" }), "document.pdf")
form.append("to", to)
const res = await fetch("https://sendfax.sh/api/v1/faxes", { method: "POST", body: form })Do not set Content-Type yourself. Passing a FormData as the body makes the runtime generate the header with its own boundary; setting it manually produces a boundary mismatch and a 400. This is the single most common multipart bug on Workers.
Also note R2ObjectBody is a stream, and streaming it directly as a request body would avoid buffering -- but you cannot, because the retry has to send the identical bytes and a stream is consumed once. Buffer to an ArrayBuffer and keep it for the whole flow.
Configuration and secrets
// wrangler.jsonc
{
"name": "fax-worker",
"main": "src/index.ts",
"compatibility_date": "2026-07-01",
// Required for `nodejs_compat`-free use of fetch/FormData/Blob -- all three are
// web standards in workerd, so no compatibility flag is needed for the SDK.
"r2_buckets": [{ "binding": "DOCS", "bucket_name": "fax-docs" }],
"vars": { "SENDFAX_API": "https://sendfax.sh", "SENDFAX_MAX_USD": "1.00" }
}# Secrets never go in wrangler.jsonc.
wrangler secret put SENDFAX_PRIVATE_KEY # Flow B only
wrangler secret put FAX_WORKER_TOKEN # your own caller authexport interface Env {
DOCS: R2Bucket
SENDFAX_API: string
SENDFAX_MAX_USD: string
SENDFAX_PRIVATE_KEY?: string
FAX_WORKER_TOKEN: string
}Secrets arrive on env, not process.env -- pass env down explicitly rather than reaching for a module-level global. A module-level const key = env.SENDFAX_PRIVATE_KEY cannot work: env only exists inside a handler.
Flow A -- no wallet: return a Checkout URL
The simplest useful Worker: accept a PDF, price it, and hand back a payment link.
export default {
async fetch(request: Request, env: Env): Promise<Response> {
if (request.method !== "POST") return new Response("POST a PDF", { status: 405 })
if (request.headers.get("authorization") !== `Bearer ${env.FAX_WORKER_TOKEN}`) {
return new Response("unauthorized", { status: 401 })
}
const form = await request.formData()
const file = form.get("document")
const to = form.get("to")
if (!(file instanceof File) || typeof to !== "string") {
return new Response("expected multipart document + to", { status: 400 })
}
const bytes = await file.arrayBuffer()
// 1. Price it. Nothing is charged; validation happens here, before payment.
const upload = new FormData()
upload.append("document", new Blob([bytes], { type: "application/pdf" }), "document.pdf")
upload.append("to", to)
const up = await fetch(`${env.SENDFAX_API}/api/v1/uploads`, { method: "POST", body: upload })
if (!up.ok) return passthroughProblem(up)
const { id, pageCount, amountCents } =
(await up.json()) as { id: string; pageCount: number; amountCents: number }
// 2. Hosted Stripe Checkout. amountCents is max(50, ceil(pages * 5)) --
// Stripe will not charge under 50 cents. lib/domain/pricing.ts:23
const co = await fetch(`${env.SENDFAX_API}/api/v1/checkout`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ id })
})
if (!co.ok) return passthroughProblem(co)
const { url } = (await co.json()) as { url: string }
return Response.json({ id, pageCount, amountCents, checkoutUrl: url, statusUrl: `${env.SENDFAX_API}/f/${id}` })
}
} satisfies ExportedHandler<Env>
/** Forward the upstream problem+json rather than inventing a message. */
async function passthroughProblem(res: Response): Promise<Response> {
return new Response(await res.text(), {
status: res.status,
headers: { "content-type": res.headers.get("content-type") ?? "application/problem+json" }
})
}Your caller opens checkoutUrl. Nothing will tell your Worker when payment lands: Checkout redirects to https://sendfax.sh/f/{id}?paid=1 (lib/payments/stripe.ts:139), not to you. The caller polls, or you poll -- see below.
Flow B -- wallet: pay the 402 from the Worker
viem works on workerd. The pattern is the same as typescript.md; what changes is where the key lives and how you survive the request lifecycle.
import { createWalletClient, http, encodeFunctionData, erc20Abi } from "viem"
import { privateKeyToAccount } from "viem/accounts"
import { base } from "viem/chains"
const USDC_BASE = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" as const
type DepositQuote = {
network: string
address: `0x${string}`
token: `0x${string}`
amountDisplay: string
amountBaseUnits: bigint
paymentIntent: string
}
/** The deposit headers the 402 carries. lib/payments/gate.ts:219-234 */
function readQuote(res: Response): DepositQuote | 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 {
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; never round-trip the
// decimal display value through a float. lib/payments/deposit.ts:16-18
amountBaseUnits: BigInt(micros),
paymentIntent: h.get("x-payment-deposit-payment-intent") ?? ""
}
}
async function sendAndPay(env: Env, bytes: ArrayBuffer, to: string) {
const body = () => {
const form = new FormData()
form.append("document", new Blob([bytes], { type: "application/pdf" }), "document.pdf")
form.append("to", to)
return form
}
const url = `${env.SENDFAX_API}/api/v1/faxes`
// 1. Unpaid submit. A 422 for an unsupported destination lands here, before
// any address is minted and before any money moves.
let res = await fetch(url, { method: "POST", body: body() })
if (res.status === 202) return (await res.json()) as Receipt
if (res.status !== 402) throw new HttpError(res.status, await res.text())
const quote = readQuote(res)
if (!quote) throw new Error("402 carried no deposit headers")
// 2. Check the charge in code before signing. The amount came over the wire.
const ceiling = BigInt(Math.round(Number(env.SENDFAX_MAX_USD) * 1_000_000))
if (quote.network !== "base") throw new Error(`unexpected network ${quote.network}`)
if (quote.token.toLowerCase() !== USDC_BASE.toLowerCase()) throw new Error("unexpected token")
if (quote.amountBaseUnits > ceiling) throw new Error(`charge ${quote.amountDisplay} over ceiling`)
const account = privateKeyToAccount(env.SENDFAX_PRIVATE_KEY as `0x${string}`)
const wallet = createWalletClient({ account, chain: base, transport: http() })
await wallet.sendTransaction({
to: quote.token, // the USDC contract
data: encodeFunctionData({
abi: erc20Abi,
functionName: "transfer",
args: [quote.address, quote.amountBaseUnits] // the deposit address
})
})
// 3. Retry the identical request until 202. Same address for ~15 minutes
// (lib/payments/deposit.ts:50), so this is not a second charge.
const deadline = Date.now() + 14 * 60_000
let delay = 3000
while (Date.now() < deadline) {
await sleep(delay)
res = await fetch(url, { method: "POST", body: body() })
if (res.status === 202) return (await res.json()) as Receipt
if (res.status !== 402) throw new HttpError(res.status, await res.text())
delay = Math.min(Math.round(delay * 1.5), 15_000)
}
throw new Error("deposit not detected within the 15-minute window")
}
type Receipt = { id: string; pageCount: number; amountUsdMicros: number; statusUrl: string }
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
class HttpError extends Error {
constructor(readonly status: number, readonly body: string) { super(`HTTP ${status}`) }
}Never send to
challenge.recipient. The MPP challenge'srecipientis the service's static receiving address (lib/runtime/index.ts:185), not your per-request deposit address. Payx-payment-deposit-address.
Using the SDK instead
Everything above also works through the SDK, with the same three adaptations documented in typescript.md -- capture the deposit headers via an injected fetch, return an informational header from the handler, and wrap send() in an outer retry loop because it only retries once (sdk/src/client.ts:117-127):
import { SendFax } from "sendfax"
const client = new SendFax({
baseUrl: env.SENDFAX_API,
fetch: capturingFetch, // see typescript.md
payment: depositHandler(quote, { privateKey: env.SENDFAX_PRIVATE_KEY!, maxMicros: ceiling })
})The SDK adds nothing you have to work around on Workers specifically -- no Node built-ins, no process, no filesystem.
Surviving the request lifecycle
This is the part that is genuinely Workers-shaped. A fetch handler's execution context ends when you return a response; any promise still in flight is cancelled. The retry loop above can run for minutes.
ctx.waitUntil for the poll
waitUntil extends the lifetime past the response, which is right for "respond immediately, keep polling in the background":
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const { bytes, to } = await parse(request)
const receipt = await sendAndPay(env, bytes, to) // awaited: the caller wants the id
// Poll to terminal after responding, so we can record the outcome.
ctx.waitUntil(recordOutcome(env, receipt.id))
return Response.json(receipt)
}
} satisfies ExportedHandler<Env>
async function recordOutcome(env: Env, id: string) {
const deadline = Date.now() + 5 * 60_000
while (Date.now() < deadline) {
const res = await fetch(`${env.SENDFAX_API}/api/v1/faxes/${encodeURIComponent(id)}`)
if (!res.ok) return
const fax = (await res.json()) as { status: string; failureReason: string | null }
if (["delivered", "failed", "expired"].includes(fax.status)) {
await env.DOCS.put(`outcomes/${id}.json`, JSON.stringify(fax))
return
}
await sleep(2000)
}
}waitUntil is not unlimited. It is bounded by the Worker's CPU and duration limits, and it is best-effort -- do not build anything that must not be lost on top of it.
What to use instead for anything durable
| Need | Use |
|---|---|
| Respond fast, best-effort follow-up | ctx.waitUntil |
| Guaranteed retry, minutes of waiting | Cloudflare Queues -- enqueue the fax id, poll from the consumer, retry on failure |
| Long-lived state, alarms, per-fax coordination | Durable Object with setAlarm to re-check |
| Periodic sweep of unfinished faxes | Cron trigger reading a KV/D1 list |
The 15-minute deposit window is the real constraint on Flow B: once you have broadcast the transfer, something must keep retrying until 202 or that transfer is stranded. If your Worker might be evicted mid-loop, do the payment and the retry loop in a Queue consumer or a Durable Object, not in a fetch handler, and persist quote.paymentIntent before broadcasting.
A Queue-based split, which is what SendFax's own dispatch pipeline does:
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const { key, to } = await parse(request)
await env.FAX_QUEUE.send({ key, to }) // returns immediately
return new Response(null, { status: 202 })
},
async queue(batch: MessageBatch<{ key: string; to: string }>, env: Env) {
for (const message of batch.messages) {
try {
const object = await env.DOCS.get(message.key)
if (!object) { message.ack(); continue }
const receipt = await sendAndPay(env, await object.arrayBuffer(), message.body.to)
await env.DOCS.put(`receipts/${receipt.id}.json`, JSON.stringify(receipt))
message.ack()
} catch {
message.retry() // queue-level backoff
}
}
}
} satisfies ExportedHandler<Env>Queue consumers get their own, longer budget and real retry semantics, which is what a payment flow deserves.
Subrequest and CPU limits
Two platform limits interact with the retry loop:
- Subrequests. Each
fetchfrom a Worker is a subrequest, and there is a per-invocation cap (50 on the Free plan, 1000 on Paid). A retry loop polling every 3-15 seconds for up to 14 minutes can plausibly issue ~80 subrequests, plus the status poll. On Free, that alone will exhaust the budget. Widen the interval, or move to Queues where each consumer invocation gets its own budget. - CPU time. Waiting on
fetchis not CPU time, so a long poll loop is cheap by that measure -- but wall-clock duration limits still apply, and they differ betweenfetch,queue,scheduled, and Durable Object alarms.
Neither is a reason to avoid Workers here; both are reasons to put the waiting in a Queue consumer rather than a request handler.
Development
wrangler devAgainst a local SendFax in FAX_MODE=mock, one header replaces the whole payment step (lib/payments/gate.ts:284-293):
// wrangler.jsonc
{ "vars": { "SENDFAX_API": "http://localhost:3001", "SENDFAX_MOCK": "1" } }const headers = env.SENDFAX_MOCK === "1" ? { "x-mock-payment": "1" } : undefined
const res = await fetch(url, { method: "POST", body: body(), headers })Note that wrangler dev runs in workerd, so a Worker reaching localhost:3001 on your machine works, but the same code deployed cannot -- point SENDFAX_API at the public API in production.
The 402 you get without the bypass header is genuine, so you can build the whole flow against the true wire shape. Mock mode also advances the status machine on read (web/app/api/v1/faxes/[id]/route.ts:24-32), so a poll loop reaches delivered in about 15 seconds.
Edge cases
Do not set Content-Type on a FormData body. The runtime generates it with the correct boundary. Setting it manually is the classic Workers multipart bug.
The document part needs a filename. new Blob(...) plus a third argument to form.append gives you one. Without it the API returns 400 Missing document (web/app/api/v1/faxes/route.ts:56-58).
422 costs nothing. to is validated and unsupported countries rejected at web/app/api/v1/faxes/route.ts:59-64, before the payment gate at line 81. Forward the upstream problem+json to your caller rather than inventing a message.
Buffer the bytes; never stream them. The retry must send identical content, and a stream is consumed once.
Per-request address, ~15-minute reuse window (lib/payments/deposit.ts:50). Identical retries are not a second charge; going quiet past the window strands your transfer. This is the strongest argument for Queues over waitUntil.
Retrying after 202 is idempotent (web/app/api/v1/faxes/route.ts:93-111), so a Queue message redelivered after a successful send returns the original receipt rather than sending a second fax. That pairs well with message.retry().
processing, not succeeded, is the settlement bar (lib/payments/deposit.ts:78-83).
Undeliverable after payment is refunded. Destination-related carrier failures on a Stripe-settled payment -- including every deposit-rail payment -- refund to the sending wallet automatically (lib/payments/refund.ts:1-30).
A Worker with a key is a spending endpoint. Authenticate your callers, cap the amount in code, and fund the wallet with a small refillable balance rather than your main one.
Cross-references
- typescript.md -- the SDK and the
PaymentHandlerin full - http.md -- the wire format, language-agnostic
- usdc.md -- how settlement works
- nextjs.md -- the same proxy pattern for a browser front end
sdk/README.md| https://sendfax.sh/openapi.json