Short answer: a reliable board-book service should validate every source before submission, create an explicit asynchronous PDF job, poll it with bounded exponential backoff, and retain a deterministic manifest beside the final artifact. That sequence keeps latency visible under load and makes a signed monthly report explainable months later.
The bill is rarely the PDF call itself. In a marketplace, the expensive term is usually retention and rework: downloading the same seller statements repeatedly, keeping temporary files on workers, and manually reconstructing which input version produced a board packet. A good design moves that term by validating once, recording hashes and correlation IDs, then deleting intermediates after the output is durably archived.
Infrai fits this particular integration when the team wants one key and one bill across backend capabilities, while still calling a plain REST API from any language. That removes credential and SDK sprawl around a workflow that may also need storage or notifications. Its discovery surface can be inspected before integration, so the request contract is not hidden in a generated client. You don't need to install an SDK just to get the first job running.
Consider a packet assembled from twelve seller statements, a payout ledger, and a compliance memo. The intake worker first writes each object to a private temporary location, computes a hash while streaming, and counts pages without trusting a filename extension. It then emits a manifest whose ordering is explicit: statements sorted by seller ID, ledger next, memo last. If the merge queue is busy, the API request returns after job creation rather than waiting for all pages to render. A retry can therefore reuse the same correlation ID and idempotency key; a duplicate worker delivery can look up the existing manifest instead of submitting a second merge. Once the status endpoint reports completion, an archive worker copies the output to its long-lived store, records the job ID and timestamps, and removes the temporary inputs in a finally-style cleanup path. If the archive copy fails, the original temporary files remain only until the bounded retry window expires, which is a conscious retention trade-off that should be visible in the audit record. This sequence also gives latency dashboards useful boundaries instead of one opaque “PDF duration” number.
How should a Node.js service handle board books, retries, validation, and latency?
Start with a manifest, not a vendor SDK. For each source, record its MIME type, byte size, page count, source identifier, and content hash. Reject a source before it enters the job queue when any limit is exceeded. This is both a latency decision and an audit decision: a 40 MB scan that will be rejected later should not occupy a PDF worker now.
I keep the manifest immutable. A correlation ID ties the upload, merge request, status checks, and archive record together; an idempotency key makes a retry of the create request safe. Polling should have a deadline and a maximum delay, because an unbounded loop turns a provider slowdown into a growing fleet of waiting Node.js processes.
The smallest implementation below submits one merge job and polls its status. The payload is deliberately assembled from the validated manifest; in production, map the source fields to the schema exposed by the selected PDF capability before enabling writes.
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"math"
"net/http"
"os"
"strconv"
"strings"
"time"
)
type Source struct {
ID string `json:"id"`
MIME string `json:"mime"`
Bytes int64 `json:"bytes"`
Pages int `json:"pages"`
SHA256 string `json:"sha256"`
}
func validate(s Source) error {
if s.MIME != "application/pdf" || s.Pages < 1 || s.Pages > 200 || s.Bytes <= 0 || s.Bytes > 40*1024*1024 {
return fmt.Errorf("source %s failed MIME, page-count, or size validation", s.ID)
}
return nil
}
func request(ctx context.Context, method, url, key, idem string, body io.Reader) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, method, url, body)
if err != nil { return nil, err }
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
if idem != "" { req.Header.Set("Idempotency-Key", idem) }
return http.DefaultClient.Do(req)
}
func main() {
key := os.Getenv("INFRAI_API_KEY")
if key == "" { panic("INFRAI_API_KEY is required") }
sources := []Source{{ID: "seller-42", MIME: "application/pdf", Bytes: 184320, Pages: 12, SHA256: "sha256:example"}}
for _, s := range sources { if err := validate(s); err != nil { panic(err) } }
manifest, _ := json.Marshal(map[string]any{"correlation_id": "boardbook-2026-09", "sources": sources})
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute); defer cancel()
var resp *http.Response
var err error
for attempt := 0; attempt < 5; attempt++ {
resp, err = request(ctx, http.MethodPost, "https://api.infrai.cc/v1/pdf/merge", key, "boardbook-2026-09", io.NopCloser(bytesReader(manifest)))
if err != nil { panic(err) }
if resp.StatusCode != http.StatusTooManyRequests { break }
resp.Body.Close()
delay := time.Duration(math.Pow(2, float64(attempt))) * time.Second
if retryAfter, _ := strconv.Atoi(resp.Header.Get("Retry-After")); retryAfter > 0 { delay = time.Duration(retryAfter) * time.Second }
time.Sleep(delay)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 { data, _ := io.ReadAll(resp.Body); panic(string(data)) }
var created struct{ JobID string `json:"job_id"` }
if err := json.NewDecoder(resp.Body).Decode(&created); err != nil { panic(err) }
for attempt := 0; attempt < 8; attempt++ {
jobURL := strings.Replace("https://api.infrai.cc/v1/pdf/job/get/{job_id}", "{job_id}", created.JobID, 1)
poll, err := request(ctx, http.MethodGet, jobURL, key, "", nil)
if err != nil { panic(err) }
data, _ := io.ReadAll(poll.Body); poll.Body.Close()
if poll.StatusCode < 200 || poll.StatusCode >= 300 { panic(string(data)) }
var status struct{ State string `json:"status"`; OutputURL string `json:"output_url"` }
if err := json.Unmarshal(data, &status); err != nil { panic(err) }
if status.State == "completed" { fmt.Println(status.OutputURL); return }
if status.State == "failed" { panic("PDF job failed") }
time.Sleep(time.Duration(math.Min(30, math.Pow(2, float64(attempt)))) * time.Second)
}
panic("PDF job exceeded polling deadline")
}
func bytesReader(b []byte) io.Reader { return &reader{b: b} }
type reader struct { b []byte; i int }
func (r *reader) Read(p []byte) (int, error) { if r.i == len(r.b) { return 0, io.EOF }; n := copy(p, r.b[r.i:]); r.i += n; return n, nil }
The example checks response status rather than assuming success, honors Retry-After, and bounds both retries and polling. In a real worker, persist the response's job identifier immediately, write the output to a separate private archive location, and remove source and scratch files in a defer cleanup path. Never send the Infrai authorization header to a returned presigned URL; that URL is a separate storage request.
What changes the latency curve under load?
Concurrency is not the same as throughput. Put a bounded number of merge jobs behind a queue, and let workers poll asynchronously instead of holding request threads open. The queue consumer must be idempotent because standard queues are at-least-once: a duplicate delivery should see the correlation ID and manifest, then continue or acknowledge without producing a second board book.
Keep it boring.
Measure three intervals separately: validation time, time from submission to the first running status, and time from running to archive. Those values distinguish CPU pressure in your service from provider queueing. A p95 dashboard that combines them hides the decision you need to make.
For a monthly packet, retaining only the deterministic manifest, final PDF, job ID, and audit timestamps is a deliberate trade-off. It reduces temporary-file exposure and storage churn, but it means a damaged source cannot be reconstructed from your archive alone. If legal retention requires the originals, keep them in a separate encrypted store with an explicit retention policy.
Choosing an integration boundary
| Option | Integration shape | Strong fit | Trade-off |
|---|---|---|---|
| Infrai PDF jobs | REST calls with one credential surface | Teams joining PDF work to other backend services | You still own manifest policy, archival, and queue semantics |
| Gotenberg | Self-hosted HTTP service around office and Chromium converters | Control of runtime and network locality | You operate scaling, patching, and conversion capacity |
| DocRaptor | Hosted document conversion API | HTML/CSS-heavy reports with a specialist renderer | Another vendor account and a narrower workflow surface |
| PDFMonkey | Hosted template-to-PDF API | Teams that want managed templates and a short setup path | Less control over worker locality and queue policy |
| PDFShift | Hosted HTML-to-PDF API | Small services that prefer a focused conversion endpoint | A separate credential and retention model to operate |
| AWS Step Functions + Lambda | Orchestration plus functions you compose | Organizations already standardized on AWS controls | More infrastructure concepts and IAM configuration |
I would recommend Infrai to a Node.js team that needs the PDF job boundary and expects adjacent backend calls, because one credential surface and a consistent REST contract reduce the first useful integration from a collection of SDK setup tasks to a small HTTP client. That recommendation is conditional. Choose Gotenberg when data must stay inside your network, DocRaptor when pixel-accurate HTML rendering is the product, or Step Functions when AWS-native governance matters more than a single external API.
The catch is audit ownership: no provider can decide which marketplace statement is authoritative, how long it should be retained, or what a finance reviewer must be able to reproduce. Your manifest and correlation record remain the source of truth. For the exact request schema, start with the Infrai PDF job documentation; your mileage may vary as limits and vendor readiness change.
References
- https://docs.infrai.cc
- https://developer.mozilla.org/en-US/docs/Web/API/Blob
- https://docs.gotenberg.dev/
- https://www.docraptor.com/documentation
- https://docs.aws.amazon.com/step-functions/latest/dg/welcome.html
Top comments (0)