Guide

Merging PDFs in a Bun server: page order, quota, and the 429 you'll eventually hit

Bun ships a fast HTTP server out of the box, so it's a common place to put a small endpoint that stitches a few PDFs together: an application packet, a signed contract plus its exhibits, a statement plus its cover letter. The merge call itself is one request. The part that actually breaks in production is what happens the day a real user's request hits your free-tier ceiling instead of your laptop's IP.

The endpoint

POST /api/merge takes a multipart body with a repeated pdf field and concatenates the inputs in the order the fields appear, not the order the filenames sort in. A Bun server accepting three uploaded files and forwarding them in a fixed order (cover letter, resume, transcript) looks like this:

// server.ts
const PDFOPS_KEY = process.env.PDFOPS_API_KEY;

Bun.serve({
  port: 3000,
  async fetch(req) {
    if (req.method !== "POST" || new URL(req.url).pathname !== "/apply") {
      return new Response("not found", { status: 404 });
    }

    const incoming = await req.formData();
    const cover = incoming.get("cover");
    const resume = incoming.get("resume");
    const transcript = incoming.get("transcript");
    if (!(cover instanceof File) || !(resume instanceof File) || !(transcript instanceof File)) {
      return Response.json({ error: "missing_file" }, { status: 400 });
    }

    // Order matters: pages concatenate in the order these fields are
    // appended, not the order they were uploaded or their filenames.
    const merged = new FormData();
    merged.append("pdf", cover, "cover.pdf");
    merged.append("pdf", resume, "resume.pdf");
    merged.append("pdf", transcript, "transcript.pdf");

    const res = await fetch("https://pdfops.dev/api/merge", {
      method: "POST",
      headers: PDFOPS_KEY ? { "X-API-Key": PDFOPS_KEY } : {},
      body: merged,
    });

    if (!res.ok) {
      const { error, details } = await res.json();
      return Response.json({ error, details }, { status: res.status });
    }

    return new Response(res.body, {
      headers: { "Content-Type": "application/pdf" },
    });
  },
});

Bun's fetch and FormData are the same web standard APIs Node and Deno expose, so this isn't Bun-specific code: it's the pattern any of the three edge-adjacent runtimes use to call PDFops. What's worth pointing at is what the endpoint does after the happy path, because that's the part every one of these worked examples tends to skip.

The 429 you'll eventually hit

Anonymous calls to /api/merge are metered per source IP at 100 requests a month. A Bun server proxying merge requests on behalf of many end users looks like one IP to PDFops, so that ceiling arrives fast: one busy afternoon, not a slow month. When it's exhausted, PDFops returns 429 with a Retry-After header set to the seconds remaining until the 1st of next month UTC, which is not a number worth waiting on inside a live request. The right move is to surface it as a clear, distinct error instead of a generic 500:

if (res.status === 429) {
  const retryAfter = res.headers.get("Retry-After");
  return Response.json(
    {
      error: "merge_quota_exhausted",
      retryAfterSeconds: retryAfter ? Number(retryAfter) : null,
    },
    { status: 503 },
  );
}

A 503 back to your own caller (rather than passing the raw 429 through) is a judgment call worth making deliberately: it tells your client "the service is temporarily unable to complete this," which is closer to the truth than "you personally are rate-limited," since the limit here belongs to your server's IP, not to the end user who just uploaded three files.

Getting past the anonymous cap

A free key raises that ceiling from 100 requests/month per IP to 250 requests/month tied to the key itself, which is the more honest unit for a server proxying requests on behalf of many users. Signing up doesn't require a card:

curl -X POST https://pdfops.dev/api/signup \
  -H "Content-Type: application/json" \
  -d '{"email":"you@example.com"}'

The key arrives by email; set it as PDFOPS_API_KEY in the Bun process's environment and the server code above already picks it up and sends it as X-API-Key. An invalid or revoked key returns 401 invalid_api_key rather than silently falling back to the anonymous IP cap, so a typo in the env var fails loudly instead of quietly eating into a shared quota.

Watching usage before you hit the wall

Waiting for a 429 to find out you're close to the ceiling means your first signal is a broken request in front of a real user. GET /api/usage reads the same counter the rate limiter enforces, so a periodic check against it can warn before the wall instead of after:

const usage = await fetch("https://pdfops.dev/api/usage", {
  headers: { "X-API-Key": PDFOPS_KEY },
}).then((r) => r.json());

// { tier: "free", limit: 250, used: 231, remaining: 19, period: "2026-08", resetsAt: "2026-09-01T00:00:00Z" }
if (usage.remaining < 20) {
  console.warn(`pdfops quota low: ${usage.remaining} of ${usage.limit} left this period`);
}

Run that check on a schedule, a Bun cron job, or just a log line at the top of a handler that fires once an hour, and the upgrade decision happens on your terms, with the usage curve in front of you, instead of during an incident.

What doesn't carry across the merge

Merging preserves every page of every input in the order supplied, but document-level structure doesn't union: AcroForm fields belong to the document, not the individual pages, so a merge of two filled forms doesn't produce one document with both sets of fields intact. If the cover letter or resume above were AcroForm templates that still needed values written in, that has to happen via POST /api/fill-form before the merge call, not after.

Related