ohmywrap
Get an API key

§

Merging PDFs from Node without a native dependency

Every PDF library in npm is either a wasm blob, a native build, or a shell-out to a binary you have to ship. Here is the HTTP version, and what it actually costs.

There are three ways to merge two PDFs from a Node service, and all of them cost you something you did not plan for.

You can pull in a pure-JavaScript library, which works until someone uploads a document with an unusual cross-reference table. You can pull in a native or wasm build, which works until you deploy to a runtime that does not have the binary, or until the base image changes and the build breaks in CI on a Friday. Or you can shell out to qpdf or pdftk, which means shipping a binary in your image and owning its CVEs.

The fourth way is not to have the dependency at all.

One POST

curl -sS --fail-with-body \
  -o merged.pdf \
  -H "Authorization: Bearer $OHMYWRAP_KEY" \
  -F "file=@invoice-jan.pdf" \
  -F "file=@invoice-feb.pdf" \
  https://api.ohmywrap.com/pdf/merge

The file part repeats, and the order of the parts is the order of the output. The response is the merged document on the same connection: no job ID, no polling, no webhook, no bucket to read it back out of.

From Node, that is FormData and fetch, both of which have been in the standard library since 18:

const form = new FormData();
form.append('file', new Blob([jan]), 'invoice-jan.pdf');
form.append('file', new Blob([feb]), 'invoice-feb.pdf');

const res = await fetch('https://api.ohmywrap.com/pdf/merge', {
  method: 'POST',
  headers: { Authorization: `Bearer ${process.env.OHMYWRAP_KEY}` },
  body: form,
});

if (!res.ok) throw new Error((await res.json()).code);
const merged = Buffer.from(await res.arrayBuffer());

The two things that will catch you

curl -o writes the body whatever the status is. A failure leaves problem JSON sitting in a file named .pdf while the terminal stays silent. Use --fail-with-body: it still saves the body, but exits non-zero and prints the failure. In code, check res.ok before you trust the bytes.

Errors carry a stable code. Match on that, never on detail:

{ "status": 400, "code": "input_count", "detail": "...", "requestId": "host/abc-000021" }

Sending one file to /pdf/merge is 400 input_count, because merging one document is not a thing anyone meant to do.

Protected inputs

If any input is encrypted, send its password as a password form field and you get a protected document back, encrypted with the same password. That is true of every transform in the API — protection is preserved rather than silently stripped, and removing it is something you ask for explicitly with /pdf/decrypt.

Under the hood pdfcpu cannot read encrypted sources when merging at all, so each input is decrypted in memory, merged, and the result re-protected. Nothing is written to disk at any point in that, because there is no disk to write to: the filesystem is read-only.

What it costs

One call. $0.001. Twenty files is one call. Three thousand pages is one call. The price does not move with the page count, which is the entire reason this exists — see pricing for the table, or the merge endpoint for the full option list.

Your first call takes one line of curl.

No plan to choose. No credits to model. No storage to configure.

10,000 calls for $10. No subscription, no expiry.