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