Filling one PDF from a webhook is a solved problem already. Filling three hundred of them from a CSV export is a different job. The API call is identical to the single-fill case. What breaks is everything around it: running out of quota at row 340, or writing a retry loop for a rate limit that resets once a month instead of once a minute.
The shape of the job
Say a workshop cohort of 180 people just wrapped, and everyone gets a completion certificate with their name, the course title, and the date baked into a fixed PDF template. The roster is a CSV export from a registration tool, and the template has three AcroForm fields: attendee_name, course_name, completion_date. The goal is a folder of 180 filled PDFs, a log of which rows failed if any did, and a script that runs the same way next cohort.
Check the quota before you start
GET /api/usage returns tier, limit, used, remaining, and the UTC period it's counting. A free key gets 250 requests a month. Run 180 fills against a key that already used 150 this period, and the result isn't 180 clean failures, it's 100 finished certificates and 80 rows that died partway through a loop with no idea it was about to run dry.
The fix is one request before the loop starts: compare the row count to usage.remaining, and stop before a single file gets written if the batch doesn't fit. A partial run of certificates is worse than no run, because now someone has to work out by hand which 100 of 180 people already have one.
The script
import { readFile, writeFile, mkdir } from "node:fs/promises";
const API_KEY = process.env.PDFOPS_API_KEY;
const TEMPLATE_PATH = "./certificate-template.pdf";
const CSV_PATH = "./attendees.csv";
const OUT_DIR = "./out";
const CONCURRENCY = 5;
function parseCsv(text) {
const [header, ...lines] = text.trim().split("\n");
const cols = header.split(",").map((c) => c.trim());
return lines.map((line) => {
const values = line.split(",");
return Object.fromEntries(cols.map((c, i) => [c, values[i]?.trim()]));
});
}
async function checkQuota(rowCount) {
const res = await fetch("https://pdfops.dev/api/usage", {
headers: { "X-API-Key": API_KEY },
});
const usage = await res.json();
if (usage.remaining < rowCount) {
throw new Error(
`Need ${rowCount} calls, only ${usage.remaining} left ` +
`(resets ${usage.resets_at}). Not starting a run I can't finish.`
);
}
console.log(`Quota OK: ${usage.remaining}/${usage.limit} left, ${rowCount} rows queued.`);
}
async function fillOne(template, row) {
const form = new FormData();
form.append("pdfFile", new Blob([template]), "template.pdf");
form.append("fields", JSON.stringify({
attendee_name: row.name,
course_name: row.course,
completion_date: row.date,
}));
form.append("flatten", "true");
const res = await fetch("https://pdfops.dev/api/fill-form", {
method: "POST",
headers: { "X-API-Key": API_KEY },
body: form,
});
if (res.status === 429) {
const err = new Error("rate_limited");
err.retryAfter = res.headers.get("retry-after");
err.fatal = true;
throw err;
}
if (!res.ok) {
return { row, ok: false, error: `${res.status} ${await res.text()}` };
}
const bytes = Buffer.from(await res.arrayBuffer());
const filename = `${row.name.replace(/\s+/g, "_")}.pdf`;
await writeFile(`${OUT_DIR}/${filename}`, bytes);
return { row, ok: true };
}
async function run() {
const [template, csv] = await Promise.all([
readFile(TEMPLATE_PATH),
readFile(CSV_PATH, "utf8"),
]);
const rows = parseCsv(csv);
await checkQuota(rows.length);
await mkdir(OUT_DIR, { recursive: true });
const queue = [...rows];
const results = [];
async function worker() {
while (queue.length) {
const row = queue.shift();
try {
results.push(await fillOne(template, row));
} catch (e) {
if (e.fatal) {
console.error(
`Rate limited mid-run. Retry-After is ${e.retryAfter}s ` +
`(next calendar month), not worth waiting out. ` +
`${queue.length + 1} rows unprocessed.`
);
const rest = [row, ...queue];
await writeFile(
"./unprocessed.csv",
rest.map((r) => `${r.name},${r.course},${r.date}`).join("\n")
);
process.exit(1);
}
results.push({ row, ok: false, error: String(e) });
}
}
}
await Promise.all(Array.from({ length: CONCURRENCY }, worker));
const failed = results.filter((r) => !r.ok);
console.log(`${results.length - failed.length}/${results.length} filled.`);
if (failed.length) {
console.log("Failed rows:", failed.map((f) => f.row.name).join(", "));
}
}
run();
Five things worth pointing at. The template PDF is read from disk once and reused for every request. fields is a JSON string inside the multipart body, matching what the endpoint expects, and flatten is literally the string "true": the API reads it off a multipart field, so a JSON boolean fails the type check silently. Five workers pull from a shared queue instead of firing 180 requests at once, which keeps the concurrency under your own control rather than whatever the server or your terminal can absorb. And a failed row lands in the results array instead of throwing, so one malformed CSV line doesn't take down 179 good ones with it.
The 429 that isn't worth retrying
Every PDFops rate-limit response carries a Retry-After header, and the instinct on seeing a 429 is to write a backoff loop: wait, retry, wait longer. That instinct is right for most APIs and wrong for this one. Retry-After here is seconds until the next calendar month, because quota resets on a monthly period rather than a sliding window. Hit the limit on the 3rd and the header can read past two million seconds. A batch script that dutifully awaits that value looks like it's still working for three and a half weeks.
The right move on a 429 mid-run is to stop, log which rows didn't get processed, and exit. The pre-flight quota check above should make this unreachable in normal operation, but it's a real failure mode if two scripts share a key, or a teammate's run eats quota between your check and your last request landing.
Try it
The free tier covers a cohort of 250 through this exact script. Sign up for a key, drop a CSV and a PDF template next to it, and swap the three field names for whatever the template uses. Field-type mapping for checkboxes, dropdowns, and radio groups is at /docs/fill-form; quota semantics are at /docs/usage.
Top comments (0)