TypeScript SDK for the PDF API: Five Minutes to a First Call, and What It Leaves to You
Use @pdf123/sdk from TypeScript to merge files, add watermarks, read file info and chain several tools into one request. It catches a misspelled tool name or parameter before anything is uploaded; server rejections carry a reason and a hint, while retries, cancellation and partial failures in a batch are yours to handle.

When you call the PDF API from Node, the @pdf123/sdk software development kit (SDK) catches a misspelled tool name or parameter before any file is uploaded. It does not retry, cancel for you, or stream results. new Pdf123Client() points anonymously at https://pdf123.xyz by default; your files are uploaded there and processed there, since there is no local engine.
Two PDFs in a folder are enough to merge
You need Node 20.3 or newer, or Bun. The package is an ES module: put the code in an .mjs file, or run npm pkg set type=module first. Put a.pdf and b.pdf in the current directory.
npm install @pdf123/sdk
// merge.mjs
import { Pdf123Client } from "@pdf123/sdk";
import { readFileInput, saveResult } from "@pdf123/sdk/node";
const client = new Pdf123Client();
const merged = await client.run("merge", {
input: [await readFileInput("a.pdf"), await readFileInput("b.pdf")],
});
console.log(await saveResult(merged, { output: "merged.pdf" }));
After node merge.mjs the terminal prints the output path you passed, merged.pdf, and the file is in the current directory. readFileInput and saveResult live in @pdf123/sdk/node. The root entry depends only on fetch and takes inputs as plain { name, data }, so an environment without a file system can use the same client. A result is { data, contentType, filename, json }; data is a whole Uint8Array, and Buffer.from(result.data) gives you a Buffer.
Merge is just one of the tools.
Files come back in data, reports in json
Every tool is client.run(toolName, { input, params }). The code below continues merge.mjs from the previous section, with client and saveResult already in scope. Where to read the result depends on the returns field from getTool, which is either "file" or "json": use data for the first, json for the second.
import { getTool } from "@pdf123/sdk";
import { readFileInput, saveResult } from "@pdf123/sdk/node";
// client comes from merge.mjs in the previous section
const marked = await client.run("watermark", {
input: await readFileInput("a.pdf"),
params: { watermarkText: "DRAFT", fontSize: 40 },
});
console.log(await saveResult(marked, { output: "marked.pdf" }));
const info = await client.run("get-info", { input: await readFileInput("a.pdf") });
console.log(info.json.FileSize, info.json.Encrypted);
console.log(getTool("get-info")?.returns); // "json"
The first block writes the watermarked file to marked.pdf; in the second, info.json is the report. You do not need to memorize field names, defaults or allowed values: getTool("watermark")?.fields is that catalog, and pdfx describe watermark on the command line reads the same one. TOOLS holds all 95 tools. The full reference is in the SDK guide on the developer page.
To merge, then watermark, then compress, you can use pipeline or three run calls in a row; which one depends on whether you want the intermediate files. Again this continues from the client above. pipeline folds the three steps into one request, the intermediate results stay on the server, and the caller gets only the last step, which the code below writes to out.pdf. If you want the file from every step, call them separately.
const result = await client.pipeline(
[{ tool: "merge" }, { tool: "watermark", params: { watermarkText: "DRAFT" } }, { tool: "compress" }],
[await readFileInput("a.pdf"), await readFileInput("b.pdf")],
);
console.log(await saveResult(result, { output: "out.pdf" }));
A pipeline has at most 8 steps. A 9th step gets HTTP 400: at most 8 pipeline steps allowed.
Anonymous, with a key, or pointed at your own server
If you set PDFX_API_BASE and still hit https://pdf123.xyz, that is because new Pdf123Client() does not read environment variables; it only looks at its constructor options.
For anonymous calls, use that constructor as is. With a key, write new Pdf123Client({ apiKey: process.env.PDFX_API_KEY }); the request header is X-API-KEY.
To point at your own server, use clientFromEnv() from @pdf123/sdk/node. It reads PDFX_API_BASE and PDFX_API_KEY. You can also write new Pdf123Client({ baseUrl: "http://localhost:8080" }) directly. If the address is unreachable the call fails; there is no offline mode. What you get and what it costs once files stay on your own network is covered in What Self-Hosting Actually Buys You (and What It Costs).
A wrong parameter is caught before the upload
Without this, the file would upload first and a misspelled field name would only surface as a 400 from the server. The parameter types for each tool are generated from the catalog, so three common slips fail at the tsc stage:
| What you write | Compiler error (excerpt) |
|---|---|
client.run("rotat", { input }) |
Argument of type '"rotat"' is not assignable to parameter of type 'ToolId' |
params: { watermarkTxt: "DRAFT" } |
Object literal may only specify known properties, but 'watermarkTxt' does not exist, ending with Did you mean to write 'watermarkText'? |
params: { angle: 45 } (rotate accepts only 90, 180, 270) |
Type '45' is not assignable to type β¦, followed by the allowed values |
Correct calls such as { angle: 90 } or { watermarkText: "DRAFT", fontSize: 30 } compile. Numeric fields accept both numbers and strings, so fontSize: 30 and fontSize: "30" are equivalent. A project that does not use TypeScript gets the same errors at runtime, and the request is never sent: the third case gives Field "angle" of "rotate" must be one of: 90, 180, 270, and a misspelled tool name gives unknown_tool, with similar names listed in the message.
When the server rejects, branch on reason
When the server rejects a request, the SDK throws Pdf123Error with status, code, reason and problem.hint. One code can have several reason values, so check reason first and fall back to code. Three common failures, run locally with the same input, look like this:
| Input | status | code | reason | hint (the original is in English) |
|---|---|---|---|---|
| Encrypted PDF, no password given | 400 | bad_request |
password_required |
Provide the document password, or unlock the PDF first |
| Wrong password | 400 | bad_request |
wrong_password |
Check the password and try again |
| Not a PDF, or a damaged file | 400 | invalid_document |
invalid_pdf |
Upload a valid, undamaged PDF; you can try repairing it first |
import { Pdf123Client, Pdf123Error } from "@pdf123/sdk";
import { readFileInput } from "@pdf123/sdk/node";
const client = new Pdf123Client();
const input = await readFileInput("locked.pdf");
try {
await client.run("compress", { input });
} catch (error) {
if (!(error instanceof Pdf123Error)) throw error;
console.error(error.status, error.code, error.reason, error.problem?.hint);
if (error.reason === "password_required") {
await client.run("compress", { input, password: "secret" });
}
}
When you know the password, pass password for a single file; unlocking and the target tool happen in the same request. For a damaged file, try Repair PDF first.
"No result" needs to be told apart. When PDF to CSV finds no table in the PDF, the server returns 204 and the SDK throws code: "no_content"; the reason is in Empty Table Export (204): Your PDF Probably Has No Columns. Conditional filter tools treat a false condition as a normal outcome: they do not throw, and return matched: false with an empty data.
Retries, memory and cancellation are the caller's job
Network errors and 5xx responses are thrown as they are; the SDK never retries on its own. For a request that changes state, pass your own idempotencyKey: resending with the same key returns the first result instead of processing again, and the mechanism and limits are in Idempotency-Key: Safe Retries for PDF Jobs. Results are read into memory whole, with no streaming. When both input and output are large, count the memory they occupy at the same time.
Cancellation and timeout are two different code values, and one call reports only the one that happens first. The example below tests them in two separate calls, so that both branches can actually run:
import { Pdf123Client, Pdf123Error } from "@pdf123/sdk";
import { readFileInput } from "@pdf123/sdk/node";
const client = new Pdf123Client();
const input = await readFileInput("a.pdf");
const controller = new AbortController();
setTimeout(() => controller.abort(), 10_000);
try {
await client.run("compress", { input, signal: controller.signal });
} catch (error) {
if (error instanceof Pdf123Error && error.code === "cancelled") { /* you cancelled it */ }
}
try {
await client.run("compress", { input, timeoutMs: 60_000 });
} catch (error) {
if (error instanceof Pdf123Error && error.code === "timeout") { /* exceeded timeoutMs; no signal this time */ }
}
As soon as the abort signal (AbortSignal) fires, the request rejects with code: "cancelled"; exceeding the timeout gives code: "timeout". Each request has a default timeout of 5 minutes, which you can change when you create the client or override per call with timeoutMs. In a chunked upload, every request carries that timeout on its own. Cancelling only closes this connection; there is no guarantee the server stops processing.
One file fails, the rest continue, and callbacks arrive in completion order
For a batch of inputs to a single-file tool, use runBatch. If one file fails, the others carry on. onResult is called the moment each file finishes, so it runs in completion order, and index is the file's position in the input array; the returned array is always in input order. Write the bytes to disk inside this callback: retainData: false empties data once the callback returns, so a result you do not saveResult here is gone. If the run is interrupted, the files already written stay.
Assume the folder has three good PDFs, a.pdf, b.pdf and an encrypted locked.pdf (password secret), plus a broken file broken.pdf that contains only a few typed characters:
import { Pdf123Client } from "@pdf123/sdk";
import { fileSource, saveResult } from "@pdf123/sdk/node";
const client = new Pdf123Client();
const results = await client.runBatch("compress", [
fileSource("a.pdf"), fileSource("broken.pdf"), fileSource("b.pdf"), fileSource("locked.pdf"),
], {
concurrency: 2,
passwordFor: (file) => (file.name === "locked.pdf" ? "secret" : undefined),
onResult: async (entry, index) => {
console.log(index, entry.input.name, entry.ok ? "ok" : entry.error.reason);
if (entry.ok) await saveResult(entry.result, { output: "out/" });
},
retainData: false,
});
By default two files are processed at a time; change that with concurrency. fileSource(path) reads a file from disk only when its turn comes, so a large batch of large files is never all in memory at once. passwordFor lets a batch that mixes locked and unlocked files run in one go. Running the four files above locally, the callback received:
1 broken.pdf invalid_pdf
0 a.pdf ok
2 b.pdf ok
3 locked.pdf ok
The completion order can differ on every run; this is just what one run looked like. retainData: false makes data in the returned array empty; the successful files were already written to out/ by the saveResult above. When you pass idempotencyKey to runBatch, the key actually sent for each file is <key>:<index>.
Chunked upload starts only above 95 MiB in total
A direct request body is limited to 100 MiB, and anything larger is rejected; see When a Large PDF Upload Is Rejected: the 100 MiB Request-Body Limit and the Error That Points You the Wrong Way for the details. When all files in one request add up to more than 95 MiB, the SDK switches to chunked upload, and your client.run call does not change. The server's default maximum for a single upload is 500 MiB; when you self-host, adjust it with PDFX_UPLOAD_MAX_BYTES.
For the same operation written in curl, MCP and the command line, see Same Operation, Four Clients: Browser, curl, MCP, pdfx: that post puts the four calls side by side, and this one only expands the SDK. The package's page on npm is @pdf123/sdk.