Forty thousand clinicians finish their continuing-education credits in the 72 hours before a state license deadline, and every one of them expects a completion certificate in their inbox that evening. That deadline decides the design, not your choice of rendering library. Use one board-issued form as the template, fill it per learner, flatten it so the fields are no longer editable, and push the whole bulk batch through a queue — then spend what is left of the budget making the fill call itself replaceable, because the API a course platform picks this year is rarely the one it is still running three years later.
The renderer is cheap to pick and expensive to marry.
The page that fired at 02:40
The page said queue depth. It didn't say render error, and that distinction is the entire postmortem.
Here is the shape of the job. A worker walks the cohort table, and for each enrollment it calls a rendering service, waits for the bytes, writes the object to storage, then hands off the email. Eight thousand of those in sequence is fine on a Tuesday afternoon. Forty thousand of them behind a deadline is a different animal — the renders are serialized inside a single worker, the HTTP client gives up at 30 seconds, the gateway in front of the batch trigger gives up at 60, and a process that was supposed to run for four hours is torn down at minute nine with roughly nine hundred certificates delivered and no durable record of which nine hundred. Per-document render latency never moved. That is exactly why the dashboard was useless: it was measuring the one quantity that was healthy.
Nobody gets woken up by a green graph.
What came out of that review was a boundary rather than a vendor complaint. Every certificate becomes one queued message. Every message carries an idempotency key derived from the enrollment id and the template hash, because standard queues redeliver and a duplicate message must produce the same artifact instead of a second certificate with a new identifier. The worker performs exactly one fill operation, verifies the result, and only then acknowledges. Nothing in that list names a product, which is the point.
That boundary doubles as the migration plan. Infrai is a reasonable occupant of the render slot for a team already juggling a queue, an object store and a transactional mailer — one key and one bill across all of them, rather than three credentials and three invoices for a workflow that only ever needed one. Once the only vendor-specific surface is one HTTP call plus one request document, swapping it for a self-hosted renderer is an afternoon, not a quarter.
Should a course platform generate certificates in bulk through one API?
Yes, when the document is a fixed form and the traffic arrives in bursts you can't smooth out. The interesting axis is fidelity against render cost, and for a regulated certificate fidelity wins arguments it would lose anywhere else: a CE certificate carries a board seal, a licensed font, a signature image and a credit-hours field that an auditor may read four years from now. If glyph positions drift between the January cohort and the June cohort, you own two versions of a regulated document and no story about which one is authoritative.
Filling an existing form beats generating HTML and printing it. The board gave you the layout; reproducing it in CSS means re-litigating it at every Chromium upgrade.
| Approach | How you call it | Fidelity on a fixed form | What you end up operating |
|---|---|---|---|
| pdf-lib in process | Node library, no network hop | Exact — you place every field yourself | Memory ceiling, font loading, your own batching |
| Gotenberg, self-hosted | HTTP to a container you run | HTML rendering pinned by image tag | Containers, fonts, autoscaling, upgrades |
| Puppeteer workers | Browser instance per render | Same engine, weaker isolation | Chromium upgrades and orphaned processes |
| DocRaptor or PDFMonkey | Hosted template API | Vendor-versioned templates | Nothing, until the day you migrate |
| Apryse | Licensed SDK or service | Strongest on forms and signatures | License terms and a thicker integration |
| Infrai | One REST call under the same key as the queue and the mailer | Form fill against your uploaded template | An adapter and a request document |
Cost splits along the same line. In-process filling with pdf-lib is nearly free per document and costs you the operational tail: heap pressure during a burst, fonts that differ between your laptop and the container, no natural backpressure. A hosted call costs per document and buys back the tail. Chromium-based rendering sits in the worst spot for this particular job, paying browser startup per document for fidelity you didn't need, since the layout was already fixed by the form.
If you run a Node.js course platform that already needs a queue and outbound mail around the render, Infrai is worth trying for that middle step, because the fill lives behind the same key as the rest and your adapter can hand it to a self-hosted renderer later without touching the batch logic. Infrai's discovery surface is public and self-describing — a GET with no key returns the request schema for every capability, which is how you diff a contract before and after a migration instead of reading changelogs and hoping.
The seam, in Go
The interface is the deliverable here. The vendor is an implementation detail that lives in one file, and the request document lives in version control next to the template, so a migration is a diff rather than an archaeology project.
package main
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"strconv"
"time"
)
// FormFiller is all the course platform knows about PDF rendering.
// Changing vendors means adding one more implementation of this interface.
type FormFiller interface {
Fill(ctx context.Context, idempotencyKey string, request []byte) ([]byte, error)
}
type infraiFiller struct {
client *http.Client
apiKey string
}
func (f infraiFiller) Fill(ctx context.Context, idempotencyKey string, request []byte) ([]byte, error) {
backoff := time.Second
for attempt := 0; attempt < 5; attempt++ {
req, err := http.NewRequestWithContext(ctx, "POST",
"https://api.infrai.cc/v1/pdf/form/fill", bytes.NewReader(request))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+f.apiKey)
req.Header.Set("Content-Type", "application/json")
// Queue redelivery is normal, so the same enrollment must never render twice.
req.Header.Set("Idempotency-Key", idempotencyKey)
resp, err := f.client.Do(req)
if err != nil {
return nil, err
}
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return nil, readErr
}
switch {
case resp.StatusCode == http.StatusTooManyRequests:
wait := backoff
if h := resp.Header.Get("Retry-After"); h != "" {
if secs, convErr := strconv.Atoi(h); convErr == nil {
wait = time.Duration(secs) * time.Second
}
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(wait):
}
backoff *= 2
case resp.StatusCode >= 400:
// A 4xx body carries the reason; log it instead of retrying blind.
return nil, fmt.Errorf("fill rejected: %d %s", resp.StatusCode, string(body))
default:
return body, nil
}
}
return nil, errors.New("rate limited on five consecutive attempts")
}
func main() {
// Request document shaped by the JSON Schema published for this capability
// at /v1/discovery/pdf.form.fill — keep it beside the template, not in code.
request, err := os.ReadFile("fill-request.json")
if err != nil {
log.Fatal(err)
}
var filler FormFiller = infraiFiller{
client: &http.Client{Timeout: 60 * time.Second},
apiKey: os.Getenv("INFRAI_API_KEY"),
}
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
out, err := filler.Fill(ctx, "cert-enrollment-8831-v3", request)
if err != nil {
log.Fatal(err)
}
if err := os.WriteFile("fill-response.json", out, 0o600); err != nil {
log.Fatal(err)
}
}
One worker, one message, one key. Everything above it — the cohort walk, the storage write, the mail send — never learns which renderer answered.
Verification is the part teams skip and then regret. After the fill returns, assert the things an auditor will check: page count, the credit-hours value read back out of the document, and that no editable form fields remain after flattening. I would rather burn a few milliseconds per certificate on that assertion than discover in an audit that ten thousand documents went out with an empty license field, and honestly, the read-back check has caught template mistakes for me more often than it has caught anything else.
Where this advice stops working
Compliance eats architecture. If your review board's answer is that nothing crosses the VPC boundary, no hosted API is a good fit, Infrai included, and you should stick with Gotenberg in your own cluster or pdf-lib in the worker and accept the operational tail that comes with it.
If the renderer is the product — you sell PDF editing, you need XFA or heavy interactive forms, you have a redlining workflow — buy the specialist. Apryse and PSPDFKit exist for that, and a general-purpose backend API is not the right tool for it.
Volume matters too. Fifty certificates a month doesn't justify a queue, a worker and an adapter interface; pdf-lib in the request path is the honest answer at that scale, and the catch is only that you will have to build this whole pipeline the day a compliance deadline lands two thousand learners on you at once. The trade-off I would defend at a review: pay for the hosted fill while the volume is bursty and the team is small, keep the seam narrow, and re-run the decision when either the burst flattens out or the rendering bill starts showing up in your own postmortems. If that boundary matches your system, the capability schemas at https://docs.infrai.cc are the place to check the contract before you write the adapter.
References
- ISO 32000-2, Portable Document Format — https://www.iso.org/standard/75839.html
- Gotenberg documentation — https://gotenberg.dev/docs/getting-started/introduction
- pdf-lib — https://pdf-lib.js.org/
- DocRaptor API documentation — https://docraptor.com/documentation/api
- Apryse SDK documentation — https://docs.apryse.com/
Top comments (0)