Guide
A 429 from /api/fill-form, and what your retry loop should actually do
A batch job fills two hundred PDFs overnight, call forty hits a 429, and the generic retry wrapper you copied from another project kicks in: wait a second, retry, wait two seconds, retry, wait four. PDFops caps are monthly, not per-minute, so that loop is backing off toward a wall it cannot back off past, and it will spend the rest of the night finding that out one doubling at a time.
What the cap actually tracks
Every PDFops endpoint meters against a monthly quota, not a
request rate: 100 requests a month per IP with no key, 250 a
month on a free key, more on indie and pro. There is no
per-second or per-minute throttle to wait out. When the count
for the current UTC month is spent, every call fails with
429 rate_limited until the first of next month,
no matter how far apart the calls are spaced.
Retry-After tells you the truth, if you read it
The 429 response carries a Retry-After header,
and on this API it is not a short number. It is seconds
until the next UTC month starts, which can be anywhere from
one second to beyond a million. A retry wrapper tuned for
typical API throttling treats any Retry-After
under some sane ceiling as obey-and-retry and anything past
it as give-up-now, and a monthly cap blows past that ceiling
on the first day of the month. Reading the header before
choosing to retry turns a silent all-night retry storm into
one log line: quota resets in 1,180,000s, bailing.
Validation failures do not cost you anything
The flip side is worth knowing before you build defensive retry logic you do not need: a 400 for a malformed field list or an unknown field name never touches the quota, and neither does a 500 on PDFops' side. Only a 2xx response counts against the monthly budget. That means a batch job can fail fast and loud on a bad template without worrying it is burning through the same number that gates working calls, and it means the only response code worth backing off for is 429, because every other failure is either yours to fix in the request or ours to fix on our side.
Check /api/usage before the batch starts, not after it 429s
A key's current count, limit, and reset time are all sitting
behind one GET call: /api/usage
with the same X-API-Key header, returning
tier, limit, used,
remaining, and resets_at. A batch
job that reads remaining before it starts and
compares it against the number of calls the batch is about
to make can stop itself at item one hundred and ninety
instead of discovering the wall at item two hundred with ten
PDFs half-written to a destination that now has a gap in it.
The script
The pattern below checks the budget up front, aborts the batch cleanly if it will not fit, and treats a mid-run 429 as a stop signal rather than something to wait out.
const API_KEY = process.env.PDFOPS_API_KEY;
const BASE = "https://pdfops.dev";
async function checkBudget(pendingCalls) {
const res = await fetch(`${BASE}/api/usage`, {
headers: { "X-API-Key": API_KEY },
});
if (!res.ok) throw new Error(`usage check failed: ${res.status}`);
const { remaining, resets_at } = await res.json();
if (remaining < pendingCalls) {
throw new Error(
`only ${remaining} calls left this period (resets ${resets_at}), ` +
`batch needs ${pendingCalls} — aborting before it starts`,
);
}
return remaining;
}
async function fillOne(templateBuf, fields) {
const form = new FormData();
form.append("pdf", new Blob([templateBuf]), "template.pdf");
form.append("fields", JSON.stringify(fields));
form.append("flatten", "true");
const res = await fetch(`${BASE}/api/fill-form`, {
method: "POST",
headers: { "X-API-Key": API_KEY },
body: form,
});
if (res.status === 429) {
const retryAfter = res.headers.get("retry-after");
const body = await res.json();
throw new Error(
`quota hit mid-batch (${body.details}); retry-after=${retryAfter}s — ` +
`stopping the run rather than sleeping through it`,
);
}
if (res.status >= 500) {
// transient on our side — worth one short retry, not a long backoff
await new Promise((r) => setTimeout(r, 1000 + Math.random() * 500));
const retry = await fetch(`${BASE}/api/fill-form`, {
method: "POST",
headers: { "X-API-Key": API_KEY },
body: form,
});
if (!retry.ok) throw new Error(`fill-form failed twice: ${retry.status}`);
return Buffer.from(await retry.arrayBuffer());
}
if (!res.ok) {
const body = await res.json();
throw new Error(`fill-form rejected the request: ${body.error} — ${body.details}`);
}
return Buffer.from(await res.arrayBuffer());
}
async function runBatch(jobs) {
await checkBudget(jobs.length);
const results = [];
for (const job of jobs) {
results.push(await fillOne(job.template, job.fields));
}
return results;
}
checkBudget only has to run once, before the
loop starts. Inside the loop, a 429 is treated as fatal
rather than something to sleep through, because the quota
that just ran out will not come back until next month no
matter how long the loop waits. A 500 gets exactly one
short, jittered retry, since that failure mode is a transient
server issue rather than a budget problem.
Try it
A free key from /docs/signup
includes 250 calls a month, enough to run this batch pattern
against a real template and watch remaining count
down in /api/usage
before wiring the budget check into a production job.