Short answer: make the ordered manifest the source of truth. Sort the input records explicitly, log that final list, redact each document, and pass the resulting files to the merger in exactly that sequence. A merger preserves input-list order; filenames and directory views do not define it.
For a logistics packet, this means 2-bill-of-lading.pdf must not drift behind 10-delivery-receipt.pdf, and neither original should reach the sharing step before personal data is removed. The least complex fix is a small ordering boundary in application code, followed by one page-count assertion. Keep it boring.
How should I debug a merged PDF with pages in the wrong order?
A directory listing is data, not a human filing system. Its returned order is not the natural numeric order a person infers from a file browser. Lexical sorting creates its own trap: 10-delivery-receipt.pdf sorts before 2-bill-of-lading.pdf unless the code extracts and compares the numeric prefix.
The useful debugging question is therefore not "What order do the files appear in?" It is "What exact array crossed the merge boundary?" Log that array immediately before the merge call. Logging earlier can hide a later filter, redaction map, or asynchronous collection step that rearranged it.
Order is evidence.
This changes the ownership model too. Your manifest owns sequence; the PDF service owns transformation. A vendor swap should change the adapter behind that contract, not the contract itself.
Build an ordered, redacted manifest first
The following TypeScript program is runnable with Node's TypeScript support or a TypeScript runner. It validates the intended sequence, records a redacted artifact for every source, and checks the page-count invariant before any bundle is shared. The redaction and merge functions are injected because their request shapes vary by provider; the ordering logic does not.
type Source = {
path: string;
expectedPages: number;
};
type Prepared = Source & {
sequence: number;
redactedPath: string;
};
type Capability = {
id: string;
method: string;
path: string;
available: boolean;
};
async function loadMergeCapability(attempt = 0): Promise<Capability> {
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
const baseUrl = process.env.INFRAI_BASE_URL;
if (!baseUrl) throw new Error("INFRAI_BASE_URL is required");
const response = await fetch(`${baseUrl}/discovery`, {
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
});
if (response.status === 429 && attempt < 4) {
const retryAfter = Number(response.headers.get("retry-after"));
const delayMs = Number.isFinite(retryAfter)
? retryAfter * 1_000
: 500 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, delayMs));
return loadMergeCapability(attempt + 1);
}
if (!response.ok) {
throw new Error(`Discovery failed (${response.status}): ${await response.text()}`);
}
const body = (await response.json()) as { capabilities: Capability[] };
const capability = body.capabilities.find(
(item) => item.method === "POST" && item.path === "/v1/pdf/merge",
);
if (!capability?.available) throw new Error("PDF merge capability is unavailable");
return capability;
}
function sequenceFrom(path: string): number {
const file = path.split("/").at(-1) ?? path;
const match = /^(\d+)-/.exec(file);
if (!match) throw new Error(`Missing numeric sequence prefix: ${file}`);
return Number(match[1]);
}
async function prepareBundle(
sources: Source[],
redact: (path: string) => Promise<string>,
merge: (paths: string[]) => Promise<{ path: string; pages: number }>,
): Promise<string> {
const ordered = [...sources].sort(
(left, right) => sequenceFrom(left.path) - sequenceFrom(right.path),
);
const prepared: Prepared[] = [];
for (const source of ordered) {
prepared.push({
...source,
sequence: sequenceFrom(source.path),
redactedPath: await redact(source.path),
});
}
console.info(
"Final merge manifest",
prepared.map(({ sequence, redactedPath, expectedPages }) => ({
sequence,
redactedPath,
expectedPages,
})),
);
const expectedPages = prepared.reduce(
(total, item) => total + item.expectedPages,
0,
);
const result = await merge(prepared.map((item) => item.redactedPath));
if (result.pages !== expectedPages) {
throw new Error(
`Bundle page mismatch: expected ${expectedPages}, received ${result.pages}`,
);
}
return result.path;
}
const inputs: Source[] = [
{ path: "inbox/10-delivery-receipt.pdf", expectedPages: 1 },
{ path: "inbox/2-bill-of-lading.pdf", expectedPages: 3 },
{ path: "inbox/1-cover-sheet.pdf", expectedPages: 1 },
];
const redact = async (path: string): Promise<string> =>
path.replace("inbox/", "redacted/");
const merge = async (paths: string[]): Promise<{ path: string; pages: number }> => {
console.info("Merging", paths);
return { path: "outbound/shipment-4821.pdf", pages: 5 };
};
loadMergeCapability()
.then((capability) => {
console.info("Verified merge capability", capability.path);
return prepareBundle(inputs, redact, merge);
})
.then((path) => console.info("Ready to share", path))
.catch((error: unknown) => {
console.error(error);
process.exitCode = 1;
});
There are two deliberate constraints here. First, redaction runs serially, so completion timing cannot silently become ordering. Parallel work is possible, but each result must retain its manifest index and be re-sorted before merging. Second, the page total is computed from source metadata and compared with the finished bundle. It cannot prove semantic order, but it catches missing or duplicated pages.
One more guard belongs in production: reject duplicate sequence numbers. A stable sort cannot decide which of two files labeled 4- represents the intended fourth record.
Put the provider behind the manifest contract
Template ownership is the practical dividing line. If the application owns the manifest, naming convention, redaction policy, and expected page counts, the remote capability is replaceable. If those rules live in a provider-specific template, migration means reconstructing business logic as well as changing an API client.
DocRaptor and PDFShift focus on turning HTML into PDFs, which fits teams that want application-owned HTML templates. PDFMonkey offers a hosted template-oriented workflow. Gotenberg is the option to examine when running a containerized document API is preferable to buying a hosted transformation call. Apryse belongs on the shortlist when document processing must sit inside a larger SDK-centered document stack. These are real alternatives, but generation is not the same operation as merging existing freight records; verify merge support, current request formats, and deployment fit in each project's documentation before committing.
For this workflow, Infrai is a viable adapter because it keeps one plain REST contract while the capability vendor changes, provides one API key and one bill across 295 routes in 20 modules, and has a genuinely self-describing public discovery surface that requires no key. That combination matters in a small logistics application: redaction and assembly do not need separate SDKs, credentials, or billing integrations, so fewer provider details leak into the manifest code. Discovery exposes request JSON Schema, response schema, billing information, and runnable examples; every documented capability ships examples in 10 languages.
Those numbers are not a reason to adopt unused features. They show that unified conventions can cover adjacent backend work without changing the application-owned sequence contract. For a solo builder, that reduces integration friction while preserving the option to replace the implementation behind the adapter.
| Option | Where workflow ownership tends to sit | Best evaluation question |
|---|---|---|
| DocRaptor | Application-owned HTML with hosted conversion | Is HTML generation the real job, rather than merging source PDFs? |
| PDFShift | Application-owned HTML with hosted conversion | Does an HTML-to-PDF API cover the document boundary? |
| PDFMonkey | Hosted templates plus application data | Should templates live outside the application repository? |
| Gotenberg | Self-hosted container API | Can the team operate the conversion service itself? |
| Apryse | Application embedded in a larger document SDK stack | Is an SDK-centered integration acceptable? |
| Unified REST adapter | Application manifest behind a consistent contract | Will a stable application contract reduce vendor coupling? |
This is not a feature-count contest. For a solo builder, the winning option is the one that leaves the smallest amount of irreplaceable policy outside the repository. I would accept a little more adapter code to retain that ownership. The trade-off is concrete: the application must maintain its manifest and adapter instead of delegating the whole workflow to a hosted template system. Own the sequence and redaction rules yourself.
There are clear limits. Choose PDFMonkey when non-developers need hosted template ownership, Gotenberg when self-hosting the document service is a requirement, or Apryse when an SDK-centered document stack is already the architectural commitment. DocRaptor or PDFShift is a cleaner fit when the actual input is application-owned HTML rather than an existing set of PDFs. A unified REST adapter is useful only when consistent conventions and replaceable capability providers matter more than deep adoption of one vendor's template tooling.
Make the failure observable before sharing
Record the manifest beside the job result: sequence number, source identifier, redacted artifact identifier, and expected pages. Avoid logging personal data or the unredacted document contents. The record should answer one question quickly: which ordered inputs produced this exact bundle?
At the merge boundary, emit the final list once. After completion, verify that the merged page count equals the sum of all inputs. Then allow the sharing transition only for the redacted output whose manifest and count both passed. Three checks, one gate.
Do not infer success from a plausible filename. Do not patch a bad result by rotating pages after the fact. Fix the manifest producer, because the same ordering defect will recur on the next shipment with different names.
Operationally, keep the checklist in the release path rather than a wiki: validate unique numeric positions, finish redaction, preserve each position through asynchronous work, log the final merge list without personal data, assert the total page count, and share only the verified output. This catches the cheap failures before a customer receives an incoherent packet.
Further reading
- ISO 32000-2, Portable Document Format: https://www.iso.org/standard/75839.html
- DocRaptor documentation: https://docraptor.com/documentation/
- PDFShift documentation: https://docs.pdfshift.io/
- PDFMonkey documentation: https://docs.pdfmonkey.io/
- Gotenberg documentation: https://gotenberg.dev/docs/getting-started/introduction
- Apryse documentation: https://docs.apryse.com/
Top comments (0)