Rate Limits and Quota Management in Document Generation APIs
Document generation is deceptively expensive. A single "render this invoice" call can spin up a headless browser, load fonts, fetch remote images, and produce a multi-megabyte PDF. That's why almost every document generation API — Kamy included — enforces rate limits and quotas. If you treat those limits as an afterthought, you'll find out the hard way during a Monday morning billing run when 4,000 invoices need to go out in fifteen minutes.
This post covers how these limits usually work, how to design your integration to respect them, and how to plan capacity so you don't get surprised.
The two limits you actually care about
Most document APIs enforce two distinct things, and it's important not to confuse them:
- Rate limits — short-window throttles, typically expressed as requests per second or per minute. These protect the provider's infrastructure from bursts.
- Quotas — long-window allowances, usually monthly, tied to your plan. These are commercial limits ("10,000 documents per month").
You can be well under your monthly quota and still get rate-limited if you fire 500 requests in one second. Conversely, a steady trickle can silently eat through your monthly quota by the 18th of the month.
Reading the response headers
Any well-behaved API tells you where you stand on every response. Kamy, for example, returns headers like:
httpHTTP/1.1 200 OK X-RateLimit-Limit: 60 X-RateLimit-Remaining: 42 X-RateLimit-Reset: 1731945600 X-Quota-Limit: 10000 X-Quota-Remaining: 7213 X-Quota-Reset: 1733011200
The two Reset values are Unix timestamps. Log these somewhere — even just to stdout — so you can graph them later. You'll find the graphs more useful than you expect.
When you cross a limit, you get a 429 Too Many Requests:
httpHTTP/1.1 429 Too Many Requests Retry-After: 12 Content-Type: application/json { "error": "rate_limit_exceeded", "message": "Rate limit exceeded. Retry after 12 seconds.", "retry_after": 12 }
Retry-After is the number of seconds to wait. Respect it. Do not roll your own guess.
Handling 429s: retry with backoff
The minimum viable implementation is exponential backoff with jitter, capped at a sensible ceiling. Here's a compact TypeScript version:
tsasync function renderWithRetry(payload: unknown, maxAttempts = 5) { for (let attempt = 0; attempt < maxAttempts; attempt++) { const res = await fetch("https://api.kamy.dev/v1/documents", { method: "POST", headers: { "Authorization": `Bearer ${process.env.KAMY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify(payload), }); if (res.status !== 429) return res; const retryAfter = Number(res.headers.get("Retry-After") ?? 1); const jitter = Math.random() * 500; const delayMs = retryAfter * 1000 + jitter; await new Promise((r) => setTimeout(r, delayMs)); } throw new Error("Rate limit retries exhausted"); }
Two things to notice: we always honor Retry-After when present, and we add jitter so that a fleet of workers doesn't all wake up in the same millisecond and re-flood the API.
Client-side throttling
Retries handle the accidental spike. But if you know you're about to generate 5,000 documents, you shouldn't fire 5,000 requests and rely on 429s to slow you down — that's rude and wasteful. Throttle on your side.
A simple token bucket works well:
tsimport pLimit from "p-limit"; // Cap at 10 concurrent requests to stay under a 60 req/min limit const limit = pLimit(10); async function renderBatch(jobs: RenderJob[]) { return Promise.all( jobs.map((job) => limit(() => renderWithRetry(job))) ); }
For jobs that don't need immediate results — nightly reports, bulk invoice runs — push them onto a queue (SQS, BullMQ, Cloud Tasks) and have workers pull at a controlled rate. This shifts backpressure to your queue instead of your API client.
Async endpoints for bulk work
If your provider offers an async or batch endpoint, use it. Kamy's async render returns a job ID immediately and delivers the finished document via webhook:
tsconst { jobId } = await kamy.documents.renderAsync({ template: "monthly-statement", data: statementData, webhookUrl: "https://api.example.com/kamy/webhook", });
Async jobs typically count against your monthly quota but not your per-second rate limit, because the provider controls the pacing internally. For anything above a few hundred documents, this is almost always the right call.
Monitoring your quota
Don't wait until you're at 100% to notice. A daily job that reads X-Quota-Remaining and pushes to your metrics system is fifty lines of code and prevents a lot of pain:
tsconst res = await fetch("https://api.kamy.dev/v1/usage", { headers: { "Authorization": `Bearer ${process.env.KAMY_API_KEY}` }, }); const usage = await res.json(); metrics.gauge("kamy.quota.remaining", usage.remaining); metrics.gauge("kamy.quota.used_pct", usage.used / usage.limit); if (usage.used / usage.limit > 0.8) {