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

```sh
npm install sendfax
```

- SDK: [`sdk/README.md`](https://github.com/DanielSinclair/sendfax) | wire format: [http.md](/guides/http.md)
- TypeScript specifics: [typescript.md](/guides/typescript.md) | settlement: [usdc.md](/guides/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 Object
```

## Which 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.

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

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

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

```jsonc
// 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" }
}
```

```sh
# Secrets never go in wrangler.jsonc.
wrangler secret put SENDFAX_PRIVATE_KEY      # Flow B only
wrangler secret put FAX_WORKER_TOKEN         # your own caller auth
```

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

---

<a name="flow-a"></a>

## Flow A -- no wallet: return a Checkout URL

The simplest useful Worker: accept a PDF, price it, and hand back a payment link.

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

---

<a name="flow-b"></a>

## Flow B -- wallet: pay the 402 from the Worker

`viem` works on workerd. The pattern is the same as
[typescript.md](/guides/typescript.md); what changes is where the key lives and
how you survive the request lifecycle.

```ts
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's `recipient` is the
> service's static receiving address (`lib/runtime/index.ts:185`), not your
> per-request deposit address. Pay `x-payment-deposit-address`.

### Using the SDK instead

Everything above also works through the SDK, with the same three adaptations
documented in [typescript.md](/guides/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`):

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

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

```ts
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 `fetch` from 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 `fetch` is not CPU time, so a long poll loop is
  cheap by that measure -- but wall-clock duration limits still apply, and they
  differ between `fetch`, `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

```sh
wrangler dev
```

Against a local SendFax in `FAX_MODE=mock`, one header replaces the whole payment
step (`lib/payments/gate.ts:284-293`):

```jsonc
// wrangler.jsonc
{ "vars": { "SENDFAX_API": "http://localhost:3001", "SENDFAX_MOCK": "1" } }
```

```ts
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](/guides/typescript.md) -- the SDK and the `PaymentHandler` in full
- [http.md](/guides/http.md) -- the wire format, language-agnostic
- [usdc.md](/guides/usdc.md) -- how settlement works
- [nextjs.md](/guides/nextjs.md) -- the same proxy pattern for a browser front end
- [`sdk/README.md`](https://github.com/DanielSinclair/sendfax) | <https://sendfax.sh/openapi.json>
