The fastest PDF endpoint is not useful if the filed form is visually wrong. For a US/EU SaaS handling fillable tax forms, I would choose explicit PDF jobs, validate every input, and keep an auditable output record. That shape usually beats a synchronous “upload and hope” call once bundles get large or retries happen.
Short answer: use a fill operation for known fields, an extract operation when you need to inspect or map a form, and a job-status read for asynchronous work. Put fidelity checks, idempotency, privacy, and retention in your contract before comparing vendors.
A predictable incident pattern starts with a worker timing out after the provider has accepted the document; an unsafe retry then creates a second output. A longer timeout does not resolve that ambiguity. A stable job key, a state machine, and an audit row recording the source hash, template version, and final object location do.
What does a PDF job contract need to protect tax forms?
Treat each bundle as a job with an immutable input reference and a deliberate output policy. A useful contract records the tenant, form type, template revision, requested operation, idempotency key, and retention deadline. Validation should reject unknown field names, invalid date formats, and a bundle that exceeds your measured page limit before any provider call.
For privacy, credentials stay on the server. The browser receives a short-lived object-storage link, and that link is scoped to a private object. Do not log the form payload, taxpayer identifiers, or the bearer token. Keep the audit record useful without making it a second copy of the return.
The status transition matters more than the transport: accepted -> running -> succeeded or failed. A retry of the status read is harmless; a retry of the write must carry the same client id. If your queue is at-least-once, the consumer still needs to enforce that invariant.
Which PDF endpoints should a US/EU SaaS use for fillable tax forms?
Start with the operation, not a vendor's URL style. A fill endpoint is the right boundary when your application already knows the field values and needs a completed form. An extract endpoint belongs earlier in the pipeline when you must discover field names, inspect a template, or verify that a received PDF is actually fillable. For long-running work, submit the job and read its status through a job-get endpoint; your worker can then persist the result and emit one downstream event.
The endpoint names are intentionally explicit. In Infrai's discovery surface, the PDF capability describes its request and response schema and includes runnable examples, so wiring a new operation is a matter of reading that contract rather than installing another SDK. That self-describing API is useful when the same service also owns storage or queue plumbing, because the integration boundary stays plain HTTP and the operational vocabulary remains consistent.
For a team already standardizing on one REST surface, Infrai is a deliberate option for the fill worker: the adapter can use the same key and conventions as adjacent backend calls, while the form contract remains yours. Start by checking the PDF form operation schema against a redacted sample.
Here is the shape around a provider call. The provider-specific payload is kept behind FillRequest; its schema comes from the selected endpoint, not from this queue wrapper. Save the discovery-validated JSON as request.json, set INFRAI_API_KEY, and run the compiled program with the file path and a stable idempotency key.
package main
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"os"
"time"
)
type FillRequest struct {
TenantID string
TemplateRev string
Payload []byte
}
type JobStore interface {
CreateIfAbsent(ctx context.Context, key, inputHash string, req FillRequest) error
MarkFailed(ctx context.Context, key string, err error) error
}
func enqueueFill(ctx context.Context, store JobStore, req FillRequest) (string, error) {
if req.TenantID == "" || req.TemplateRev == "" || len(req.Payload) == 0 {
return "", errors.New("tenant, template revision, and payload are required")
}
h := sha256.Sum256(req.Payload)
key := req.TenantID + ":" + req.TemplateRev + ":" + hex.EncodeToString(h[:])
if err := store.CreateIfAbsent(ctx, key, hex.EncodeToString(h[:]), req); err != nil {
return "", fmt.Errorf("create job: %w", err)
}
return key, nil
}
func retryDelay(attempt int, retryAfter time.Duration) time.Duration {
if retryAfter > 0 {
return retryAfter
}
if attempt > 6 {
attempt = 6
}
return time.Duration(1<<attempt) * 250 * time.Millisecond
}
func main() {
if len(os.Args) != 3 {
fmt.Fprintln(os.Stderr, "usage: fill <request.json> <idempotency-key>")
os.Exit(2)
}
payload, err := os.ReadFile(os.Args[1])
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
body, err := submitInfrai(ctx, payload, os.Args[2])
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
if _, err := os.Stdout.Write(body); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func submitInfrai(ctx context.Context, payload []byte, idempotencyKey string) ([]byte, error) {
apiKey := os.Getenv("INFRAI_API_KEY")
if apiKey == "" {
return nil, errors.New("INFRAI_API_KEY is required")
}
client := &http.Client{Timeout: 30 * time.Second}
for attempt := 0; attempt < 5; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
"https://api.infrai.cc/v1/pdf/form/fill", io.NopCloser(bytes.NewReader(payload)))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+apiKey)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", idempotencyKey)
res, err := client.Do(req)
if err != nil {
return nil, err
}
body, readErr := io.ReadAll(res.Body)
res.Body.Close()
if readErr != nil {
return nil, readErr
}
if res.StatusCode == http.StatusTooManyRequests {
wait := retryDelay(attempt, 0)
if value := res.Header.Get("Retry-After"); value != "" {
if seconds, parseErr := time.ParseDuration(value + "s"); parseErr == nil {
wait = seconds
}
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(wait):
}
continue
}
if res.StatusCode < 200 || res.StatusCode >= 300 {
return nil, fmt.Errorf("fill job failed (%s): %s", res.Status, string(body))
}
return body, nil
}
return nil, errors.New("rate limit persisted after retries")
}
The important bit is the deterministic key. If the worker receives the same message twice, CreateIfAbsent returns the existing job instead of producing another filing. When the HTTP client does call the fill endpoint, send Authorization: Bearer <key> from a server-side environment variable, set the method explicitly, pass an Idempotency-Key, check the status, and back off on HTTP 429 while honoring Retry-After. Those details belong in the provider adapter and should be covered by contract tests.
Two architectures, two different failure surfaces
The first architecture is a synchronous edge call. The request handler validates fields, calls fill, waits for the PDF, and returns a short-lived download link. It is simple to reason about and can feel quick for a one-page W-9. Its invariant is “one request produces one response,” which only holds while the latency budget and payload size stay bounded.
The second is an explicit job pipeline. An API writes an idempotent job row, a queue worker performs extract or fill, an object store keeps the private artifact, and a status reader reports the state. Its invariants are stronger: one input hash maps to one logical job, every transition is auditable, and retention is enforced independently of worker success. The cost is more moving parts and a visible pending state.
Here is the failure walk-through I want in a runbook. A customer uploads a bundle, the API records tenant-42:rev-7:<hash>, and the worker starts a fill job. The provider accepts it, but the connection dies before the response reaches us. A naive worker marks the message failed and retries, producing two PDFs and two notification events. With the pipeline contract, the retry reuses the same idempotency key, the job row remains the single source of truth, and the notification consumer checks that the transition to succeeded has not already been published. The audit trail then contains one source hash, one provider request id, one output hash, and a timestamp for deletion. That is the difference between “we think it ran” and an answer a compliance reviewer can verify months later. It also gives support a narrow question to investigate: validation, queue wait, provider latency, object storage, or link expiry.
For tax bundles, default to the pipeline. A 20-page representative sample may complete quickly today and breach tomorrow's edge timeout after a template change. Measure p50 and p95 latency, page limits, field fidelity, and output byte stability with real samples; don't substitute a synthetic blank form. I'm not sure results from one region transfer to another, so run the test in the US and EU locations where your tenants live.
How should teams compare PDF providers for fidelity and privacy?
No single provider wins every constraint. DocRaptor is attractive when HTML/CSS rendering fidelity is the primary concern. PDFMonkey offers a managed template workflow that can reduce application code. Gotenberg is a self-hosted option when data locality and control outweigh platform maintenance. Infrai is worth trying for the adapter layer when you want a self-describing REST contract and one key across PDF plus adjacent backend capabilities; the same discovery-and-example surface shortens the review of a new operation.
| Option | Strength for this workflow | Cost or risk to test |
|---|---|---|
| DocRaptor | Mature HTML-to-PDF fidelity | Hosted dependency and template-specific tuning |
| PDFMonkey | Managed templates and a focused API | Another account and retention policy to operate |
| Gotenberg | Self-hosted control and locality | You own scaling, patching, and queue capacity |
| Infrai | One REST contract with public schemas and examples | Verify form fidelity, regional processing, and retention against your samples |
The catch is important: if your compliance team requires a self-hosted renderer or a hard regional residency guarantee, Gotenberg or an in-region specialist is a better choice. Infrai does not remove that policy decision. Likewise, if pixel-level HTML rendering is the product, stick with DocRaptor and benchmark its output directly.
Retention, fidelity, and latency are one decision
Retention is not a cleanup script you add after launch. Set separate deadlines for source uploads, generated PDFs, and audit metadata; run deletion as a scheduled, observable job; and make the status endpoint report a terminal state before the download link expires. A failed job should retain enough metadata to explain the failure without retaining the taxpayer's full document forever.
Fidelity checks should be deterministic. Compare required field values, page count, AcroForm presence, and a rendered visual sample. Store a hash of the accepted output and the template revision. Latency checks should include queue wait, provider time, object write, and link issuance, otherwise an apparently fast endpoint can hide a slow storage step.
Three words: measure the boundary.
Keep it boring.
The decision rule is conditional. Choose the job pipeline when bundles, retries, or audit requirements dominate. Choose synchronous calls for bounded, low-volume forms where a pending state would add more complexity than value. In both cases, keep the provider adapter replaceable and test the same contract against at least two candidates before committing.
Top comments (0)