Generating a PDF is rarely the end of the job. A board pack is a cover page, a report and three appendices that must arrive as one file. A contract packet needs page numbers so people can say "clause 4 on page 7" on a call. Uploaded PDFs need their text pulled out so search works, and a thumbnail so the file list is not a column of identical icons.
This lesson uses four APIs, each doing one of those things, and chains them into a pipeline. Each costs 10 credits per call.
| API | Does | Input |
|---|---|---|
| PDF Merge | Joins 2 to 10 PDFs in order | urls array, or uploads |
| PDF Page Numbers | Stamps a number on each page | Upload in field pdf |
| PDF to Text | Extracts the text layer | Upload in field pdf |
| PDF to Image | Renders pages as PNG or JPG | pdf URL, or upload |
All four reject encrypted PDFs; remove the password first.
Merging: order is the whole contract
PDF Merge takes urls, a JSON array of 2 to 10 public PDF URLs, and merges them in the order you send. Alternatively upload the files as multipart in the field pdfs. Limits: 10 MB per file, 25 MB in total, and at most 500 pages in the result. Pages are copied whole, so fonts, images and page sizes are preserved, even when the inputs have different page sizes.
curl -X POST "https://api.apiverve.com/v1/pdfmerge" \
-H "x-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"urls":["https://raw.githubusercontent.com/mozilla/pdf.js/master/test/pdfs/rotation.pdf","https://raw.githubusercontent.com/mozilla/pdf.js/master/test/pdfs/sizes.pdf"]}'pageCount should equal the sum of the inputs' pages: 2 + 3 here.
The response gives fileCount, pageCount, size and the usual downloadURL (valid for 2 days) with expires in milliseconds. Paid plans add sources: for each input, its index, name, pageCount, startPage and endPage in the merged file. That table is what you need to build a table of contents ("Appendix B starts on page 14") without counting pages yourself. For the two sample files above, it looks like this:
{
"fileCount": 2,
"pageCount": 5,
"sources": [
{ "index": 1, "name": "rotation.pdf", "pageCount": 2, "startPage": 1, "endPage": 2 },
{ "index": 2, "name": "sizes.pdf", "pageCount": 3, "startPage": 3, "endPage": 5 }
]
}
Send one URL and the call fails with "send at least 2 PDFs to merge". If your code assembles a packet from optional parts, handle the one-part case by skipping the merge, not by sending the same file twice.
Chaining without downloading
Here is the trick that makes these APIs compose. Every file APIVerve generates comes back as a signed downloadURL on a public storage host, and PDF Merge accepts public URLs. So you can pass the downloadURLs from HTML to PDF and Office to PDF straight into a merge without downloading anything in between. We merged a 2-page HTML render with a 2-page converted Word document that way and got pageCount: 4.
Two cautions. The links expire, so chain promptly rather than storing them for a nightly batch. And a signed URL is a bearer credential for that file until it expires: do not log it at info level or paste it into tickets when the document contains personal data.
You merge cover.pdf (1 page), report.pdf (12 pages) and appendix.pdf (5 pages). Which check best confirms the merge did what you expected?
Answer the check above to continue.
Previews with PDF to Image
PDF to Image renders chosen pages with Poppler as PNG or JPG. Unlike the previous two APIs, it accepts a pdf URL as well as an upload, so you can point it straight at a downloadURL.
curl -X POST "https://api.apiverve.com/v1/pdftoimage" \
-H "x-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"pdf":"https://raw.githubusercontent.com/mozilla/pdf.js/master/web/compressed.tracemonkey-pldi-09.pdf","pages":"1","format":"png"}'pageCount is the whole document; images holds one entry per rendered page with its pixel size and link.
Parameters: pages as numbers and ranges such as 1-3,5 (default 1), format (png, jpg or jpeg), quality from 1 to 100 for JPG, and on paid plans dpi from 72 to 300 (default 150). The Free plan renders one page per call at 150 dpi; paid plans render up to 10 pages per call. Pages that would exceed 25 megapixels are rendered at a lower dpi, and each entry in images reports the dpi it actually got.
The response has pageCount for the whole document, imageCount, and images[], each with page, imageName, width, height, dpi, size and a downloadURL valid for 2 days. The 14-page sample above renders page 1 at 1275 by 1650 pixels: 8.5 by 11 inches at 150 dpi, so a US Letter page. An A4 document at the same dpi came back at 1241 by 1754. Width and height tell you the page size without opening the file.
For thumbnails, JPG at the default quality is usually a fraction of the PNG size for photographic pages; PNG stays sharper for text-only pages. Render page 1 at upload time and cache it. A preview generated on every page view is 10 credits per view for an image that never changes.
The pipeline
Here is the whole flow in Node 18 (ESM): merge a cover and a report by URL, number the result, extract its text to verify and index it, and render a thumbnail of page 1.
import { writeFile } from 'node:fs/promises';
const API = 'https://api.apiverve.com/v1';
const KEY = process.env.APIVERVE_API_KEY;
async function parse(res, api) {
const body = await res.json().catch(() => null);
if (!res.ok || body?.status !== 'ok') throw new Error(`${api}: ${body?.error || `HTTP ${res.status}`}`);
return body.data;
}
const postJson = async (api, payload) =>
parse(await fetch(`${API}/${api}`, {
method: 'POST',
headers: { 'x-api-key': KEY, 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
signal: AbortSignal.timeout(60000),
}), api);
async function postPdf(api, bytes, fields = {}) {
const form = new FormData();
form.append('pdf', new Blob([bytes], { type: 'application/pdf' }), 'document.pdf');
for (const [k, v] of Object.entries(fields)) form.append(k, String(v));
return parse(await fetch(`${API}/${api}`, {
method: 'POST', headers: { 'x-api-key': KEY }, body: form, signal: AbortSignal.timeout(60000),
}), api);
}
const download = async (url) => Buffer.from(await (await fetch(url)).arrayBuffer());
export async function buildPacket(partUrls, mustContain) {
const merged = await postJson('pdfmerge', { urls: partUrls });
const numbered = await postPdf('pdfpagenumbers', await download(merged.downloadURL), { position: 'bottom-right' });
if (numbered.pageCount !== merged.pageCount) throw new Error('Page count changed while numbering');
const finalPdf = await download(numbered.downloadURL);
const text = await postPdf('pdftotext', finalPdf);
if (!text.hasText) throw new Error('Packet has no text layer');
for (const s of mustContain) if (!text.text.includes(s)) throw new Error(`Missing "${s}" in packet`);
const thumb = await postJson('pdftoimage', { pdf: numbered.downloadURL, pages: '1', format: 'jpg' });
await writeFile('packet.pdf', finalPdf);
await writeFile('packet-p1.jpg', await download(thumb.images[0].downloadURL));
return { pages: merged.pageCount, words: text.wordCount, truncated: text.truncated };
}
console.log(await buildPacket(
['https://raw.githubusercontent.com/mozilla/pdf.js/master/test/pdfs/rotation.pdf',
'https://raw.githubusercontent.com/mozilla/pdf.js/master/test/pdfs/sizes.pdf'],
[],
));import os
import sys
import requests
API = "https://api.apiverve.com/v1"
def pdf_to_text(path):
with open(path, "rb") as f:
res = requests.post(f"{API}/pdftotext",
files={"pdf": (os.path.basename(path), f, "application/pdf")},
headers={"x-api-key": os.environ["APIVERVE_API_KEY"]}, timeout=60)
body = res.json()
if res.status_code != 200 or body.get("status") != "ok":
raise RuntimeError(body.get("error") or f"HTTP {res.status_code}")
return body["data"]
def index_record(path):
data = pdf_to_text(path)
if not data["hasText"]:
return {"path": path, "status": "needs_ocr", "pages": data["pageCount"]}
return {
"path": path,
"status": "partial" if data["truncated"] else "complete",
"pages": data["pageCount"],
"pagesIndexed": data["pagesExtracted"],
"words": data["wordCount"],
"text": data["text"],
}
if __name__ == "__main__":
record = index_record(sys.argv[1])
print({k: v for k, v in record.items() if k != "text"})Notice what the Node pipeline checks at each step: the page count survives numbering, the final file has a text layer, and the strings you expect are in it. Each check is one comparison against a field the API already returned. When a step fails, you know which one, instead of discovering a broken packet in a customer's inbox. The mustContain list is empty for the public sample files; for your own documents, pass the invoice number, the customer name and the total.
The order matters too: number after merging, extract text after numbering (so the page labels are part of the indexed text and you have verified the file you will actually send), and render the thumbnail from the final file. In this pipeline the per-packet cost is 40 credits: merge, number, extract and one thumbnail at 10 each.
Where to go from here
That completes PDFs: you can generate them from HTML, web pages, Markdown and office files, and assemble, number and read them back. The next course in this certification moves to images: converting formats, extracting text from scans (the fix for hasText: false), and processing media. Start at /academy/work-with-images.